diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 6584abb..e79993e 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -15,6 +15,10 @@ final class MouseEventGenerator: @unchecked Sendable { /// Minimum movement threshold in pixels to prevent jitter var minimumMovementThreshold: CGFloat = 0.5 + + /// Timeout in seconds for stuck drag detection (no activity = stuck) + /// After this many seconds without updateDrag calls, the drag is auto-released + var stuckDragTimeout: TimeInterval = 10.0 // State tracking - protected by stateLock for thread safety // isMiddleMouseDown is read from multiple threads (updateDrag on gesture queue, @@ -26,10 +30,20 @@ final class MouseEventGenerator: @unchecked Sendable { set { stateLock.withLock { _isMiddleMouseDown = newValue } } } + /// Drag session generation counter - protected by stateLock + /// Must be accessed atomically with isMiddleMouseDown to prevent race conditions + private var _dragGeneration: UInt64 = 0 + private var eventSource: CGEventSource? // Event generation queue for thread safety private let eventQueue = DispatchQueue(label: "com.middledrag.mouse", qos: .userInitiated) + + // Watchdog timer for stuck drag detection + private var watchdogTimer: DispatchSourceTimer? + private let watchdogQueue = DispatchQueue(label: "com.middledrag.watchdog", qos: .utility) + private var lastActivityTime: CFTimeInterval = 0 + private let activityLock = NSLock() // Smoothing state for EMA (exponential moving average) private var previousDeltaX: CGFloat = 0 @@ -53,6 +67,15 @@ final class MouseEventGenerator: @unchecked Sendable { /// Start a middle mouse drag operation /// - Parameter screenPosition: Starting position (used for reference, actual position from current cursor) func startDrag(at screenPosition: CGPoint) { + // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags + // This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP + if isMiddleMouseDown { + Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) + // Send mouse up for the existing drag immediately (synchronously) + let currentPos = currentMouseLocationQuartz + sendMiddleMouseUp(at: currentPos) + } + // Initialize position synchronously to prevent race conditions with updateDrag let quartzPos = currentMouseLocationQuartz positionLock.lock() @@ -62,6 +85,11 @@ final class MouseEventGenerator: @unchecked Sendable { // Reset smoothing state previousDeltaX = 0 previousDeltaY = 0 + + // Record activity time for watchdog + activityLock.lock() + lastActivityTime = CACurrentMediaTime() + activityLock.unlock() // CRITICAL: Both flag AND mouse-down event must be set/sent SYNCHRONOUSLY. // This prevents two race conditions: @@ -70,8 +98,17 @@ final class MouseEventGenerator: @unchecked Sendable { // // sendMiddleMouseDown() just creates and posts a CGEvent, which is thread-safe // and takes microseconds. No need for async dispatch here. - isMiddleMouseDown = true + // + // CRITICAL: isMiddleMouseDown and dragGeneration must be set ATOMICALLY + // to prevent race with forceReleaseDrag() on watchdogQueue + stateLock.withLock { + _isMiddleMouseDown = true + _dragGeneration &+= 1 + } sendMiddleMouseDown(at: quartzPos) + + // Start watchdog timer to detect stuck drags + startWatchdog() } /// Magic number to identify our own events (0x4D44 = 'MD') @@ -83,6 +120,11 @@ final class MouseEventGenerator: @unchecked Sendable { /// - deltaY: Vertical movement delta func updateDrag(deltaX: CGFloat, deltaY: CGFloat) { guard isMiddleMouseDown else { return } + + // Record activity time for watchdog (drag is still active) + activityLock.lock() + lastActivityTime = CACurrentMediaTime() + activityLock.unlock() // Apply consistent smoothing to both horizontal and vertical movement // Uses the user's configured smoothing factor for both axes @@ -213,6 +255,9 @@ final class MouseEventGenerator: @unchecked Sendable { /// End the drag operation func endDrag() { guard isMiddleMouseDown else { return } + + // Stop watchdog since drag is ending normally + stopWatchdog() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid start/end cycles and ensures @@ -295,6 +340,9 @@ final class MouseEventGenerator: @unchecked Sendable { /// Cancel any active drag operation func cancelDrag() { guard isMiddleMouseDown else { return } + + // Stop watchdog since drag is being cancelled + stopWatchdog() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid cancel/start cycles and ensures @@ -316,6 +364,45 @@ final class MouseEventGenerator: @unchecked Sendable { self.sendMiddleMouseUp(at: currentPos) } } + + /// Force send a MIDDLE_UP event regardless of internal state + /// Used for manual recovery when system state may be out of sync with our tracking + /// Unlike cancelDrag(), this ALWAYS sends an UP event even if isMiddleMouseDown is false + func forceMiddleMouseUp() { + Log.warning("Force sending MIDDLE_UP (unconditional)", category: .gesture) + + // Stop watchdog if running + stopWatchdog() + + // Atomically reset state and capture generation + let currentGeneration: UInt64 = stateLock.withLock { + _isMiddleMouseDown = false + return _dragGeneration + } + + // Always send the UP event synchronously + let pos = currentMouseLocationQuartz + sendMiddleMouseUp(at: pos) + + // Also send async as fallback (in case sync was swallowed) + eventQueue.async { [weak self] in + guard let self = self else { return } + + // Only proceed if no new drag started + let newGeneration = self.stateLock.withLock { self._dragGeneration } + guard newGeneration == currentGeneration else { return } + + // Reset state and send another UP + self.previousDeltaX = 0 + self.previousDeltaY = 0 + self.positionLock.lock() + self.lastSentPosition = nil + self.positionLock.unlock() + + let pos = self.currentMouseLocationQuartz + self.sendMiddleMouseUp(at: pos) + } + } // MARK: - Coordinate Conversion @@ -411,4 +498,140 @@ final class MouseEventGenerator: @unchecked Sendable { event.flags = [] event.post(tap: .cghidEventTap) } + + // MARK: - Watchdog Timer + + /// Start the watchdog timer to detect stuck drags + /// All watchdogTimer access is synchronized on watchdogQueue to prevent data races + private func startWatchdog() { + watchdogQueue.async { [weak self] in + guard let self = self else { return } + + // Cancel any existing timer first + self.stopWatchdogLocked() + + let timer = DispatchSource.makeTimerSource(queue: self.watchdogQueue) + timer.schedule(deadline: .now() + 1.0, repeating: 1.0) // Check every second + + timer.setEventHandler { [weak self] in + self?.checkForStuckDrag() + } + + self.watchdogTimer = timer + timer.resume() + } + } + + /// Stop the watchdog timer (thread-safe wrapper) + private func stopWatchdog() { + watchdogQueue.async { [weak self] in + self?.stopWatchdogLocked() + } + } + + /// Stop the watchdog timer - must be called only on watchdogQueue + private func stopWatchdogLocked() { + watchdogTimer?.cancel() + watchdogTimer = nil + } + + /// Check if the drag has become stuck (no activity for too long) + /// Called on watchdogQueue + private func checkForStuckDrag() { + // CRITICAL: Atomically read both isMiddleMouseDown AND generation + // This captures which drag session we're checking, preventing race with startDrag() + let (isDragging, capturedGeneration): (Bool, UInt64) = stateLock.withLock { + (_isMiddleMouseDown, _dragGeneration) + } + + guard isDragging else { + // Drag already ended, stop checking + stopWatchdogLocked() + return + } + + activityLock.lock() + let lastActivity = lastActivityTime + activityLock.unlock() + + let timeSinceActivity = CACurrentMediaTime() - lastActivity + + if timeSinceActivity > stuckDragTimeout { + // Drag appears to be stuck - auto-release + Log.warning( + "Stuck drag detected - no activity for \(String(format: "%.1f", timeSinceActivity))s, auto-releasing", + category: .gesture + ) + + // Log to Sentry if telemetry is enabled + if CrashReporter.shared.anyTelemetryEnabled { + let attributes: [String: Any] = unsafe [ + "category": "gesture", + "event": "stuck_drag_auto_release", + "time_since_activity": timeSinceActivity, + "timeout_threshold": stuckDragTimeout, + "session_id": Log.sessionID, + ] + unsafe SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes) + } + + // Force release the stuck drag, passing the generation we captured + // This ensures we don't interfere with a new drag that started since our check + forceReleaseDrag(forGeneration: capturedGeneration) + } + } + + /// Force release a stuck drag without normal cleanup flow + /// This is called by the watchdog when a drag appears stuck + /// Called on watchdogQueue + /// - Parameter forGeneration: The generation that was captured when deciding to release. + /// If current generation doesn't match, a new drag has started and we abort. + private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) { + stopWatchdogLocked() + + // CRITICAL: Verify generation still matches before clearing state + // This prevents race where a new drag started between checkForStuckDrag() and now + let releasedGeneration: UInt64? = stateLock.withLock { + guard _dragGeneration == expectedGeneration else { + // A new drag has started - don't interfere with it! + Log.info("forceReleaseDrag aborted - new drag session started (expected gen \(expectedGeneration), current \(_dragGeneration))", category: .gesture) + return nil + } + _isMiddleMouseDown = false + return _dragGeneration + } + + // If generation didn't match, abort without sending any events + guard let releasedGeneration = releasedGeneration else { + return + } + + // Send mouse up event synchronously to ensure it gets through + let currentPos = currentMouseLocationQuartz + sendMiddleMouseUp(at: currentPos) + + // Also try sending via async queue in case the sync one gets swallowed + // Only proceeds if no new drag has started (same generation) + eventQueue.async { [weak self] in + guard let self = self else { return } + + // Check if a new drag started - don't interfere with it + let currentGeneration = self.stateLock.withLock { self._dragGeneration } + guard currentGeneration == releasedGeneration else { + Log.info("Skipping async force-release cleanup - new drag session started", category: .gesture) + return + } + + // Reset smoothing state + self.previousDeltaX = 0 + self.previousDeltaY = 0 + self.positionLock.lock() + self.lastSentPosition = nil + self.positionLock.unlock() + + // Send another mouse up as a fallback + let pos = self.currentMouseLocationQuartz + self.sendMiddleMouseUp(at: pos) + } + } } diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 459b8e9..77a61cf 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -306,6 +306,32 @@ final class MultitouchManager: @unchecked Sendable { configuration = config applyConfiguration() } + + /// Force release any stuck middle-drag state + /// This can be called manually by the user (e.g., from menu bar) if they notice + /// the middle button is stuck. It sends a MIDDLE_UP event regardless of current state. + func forceReleaseStuckDrag() { + // Dispatch to main thread to avoid data races with gesture state updates + // which are also dispatched to main thread (see GestureRecognizerDelegate methods) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + Log.info("Force releasing stuck drag (user triggered)", category: .gesture) + + // Reset all internal state + self.isActivelyDragging = false + self.isInThreeFingerGesture = false + self.gestureEndTime = CACurrentMediaTime() + self.lastGestureWasActive = false + + // Force send MIDDLE_UP unconditionally + // Unlike cancelDrag(), this always sends UP even if internal state is already false + self.mouseGenerator.forceMiddleMouseUp() + + // Also reset the gesture recognizer to ensure clean state + self.gestureRecognizer.reset() + } + } // MARK: - Event Tap diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 492002c..7b345a6 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -537,4 +537,275 @@ final class MouseEventGeneratorTests: XCTestCase { generator.endDrag() } + + // MARK: - Stuck Drag Prevention Tests + + func testDefaultStuckDragTimeout() { + // Default timeout should be 10 seconds + XCTAssertEqual(generator.stuckDragTimeout, 10.0, accuracy: 0.001) + } + + func testStuckDragTimeoutCanBeModified() { + generator.stuckDragTimeout = 5.0 + XCTAssertEqual(generator.stuckDragTimeout, 5.0, accuracy: 0.001) + } + + func testDoubleStartDragCancelsExistingDrag() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let firstStartExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + firstStartExpectation.fulfill() + } + wait(for: [firstStartExpectation], timeout: 0.5) + + // Start second drag without ending first - should not crash + // The existing drag should be cancelled automatically + XCTAssertNoThrow(generator.startDrag(at: CGPoint(x: 200, y: 200))) + + let secondStartExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondStartExpectation.fulfill() + } + wait(for: [secondStartExpectation], timeout: 0.5) + + // Updates should still work after double-start + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + generator.endDrag() + } + + func testTripleStartDragHandledGracefully() { + // Start three drags in quick succession + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let exp1 = XCTestExpectation(description: "First start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + exp1.fulfill() + } + wait(for: [exp1], timeout: 0.5) + + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + let exp2 = XCTestExpectation(description: "Second start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + exp2.fulfill() + } + wait(for: [exp2], timeout: 0.5) + + generator.startDrag(at: CGPoint(x: 300, y: 300)) + + let exp3 = XCTestExpectation(description: "Third start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp3.fulfill() + } + wait(for: [exp3], timeout: 0.5) + + // Should be able to update and end normally + XCTAssertNoThrow(generator.updateDrag(deltaX: 5.0, deltaY: 5.0)) + XCTAssertNoThrow(generator.endDrag()) + } + + func testRapidStartEndCycles() { + // Test rapid start/end cycles to stress test the double-start guard + for i in 1...5 { + generator.startDrag(at: CGPoint(x: CGFloat(i * 100), y: CGFloat(i * 100))) + + let startExp = XCTestExpectation(description: "Start \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + generator.endDrag() + + let endExp = XCTestExpectation(description: "End \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + endExp.fulfill() + } + wait(for: [endExp], timeout: 0.5) + } + } + + func testStartDragWhileDraggingThenCancel() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Start second drag (should auto-cancel first) + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + let secondExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondExpectation.fulfill() + } + wait(for: [secondExpectation], timeout: 0.5) + + // Now cancel - should only cancel the second drag + XCTAssertNoThrow(generator.cancelDrag()) + + let cancelExpectation = XCTestExpectation(description: "Drag cancelled") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + cancelExpectation.fulfill() + } + wait(for: [cancelExpectation], timeout: 0.5) + + // Updates should be ignored after cancel + XCTAssertNoThrow(generator.updateDrag(deltaX: 50.0, deltaY: 50.0)) + } + + func testUpdateDragResetsActivityTime() { + // This test verifies that updateDrag updates activity tracking + // (the watchdog won't trigger if updates keep happening) + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Perform multiple updates with small delays + for i in 1...3 { + XCTAssertNoThrow(generator.updateDrag(deltaX: CGFloat(i), deltaY: CGFloat(i))) + + let updateExp = XCTestExpectation(description: "Update \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + updateExp.fulfill() + } + wait(for: [updateExp], timeout: 0.5) + } + + generator.endDrag() + } + + func testWatchdogTimerStartsAndStopsWithDrag() { + // Start drag - watchdog should start + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started with watchdog") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // End drag - watchdog should stop + generator.endDrag() + + let endExpectation = XCTestExpectation(description: "Drag ended, watchdog stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + endExpectation.fulfill() + } + wait(for: [endExpectation], timeout: 0.5) + } + + func testCancelDragStopsWatchdog() { + // Start drag - watchdog should start + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Cancel drag - watchdog should stop + generator.cancelDrag() + + let cancelExpectation = XCTestExpectation(description: "Drag cancelled, watchdog stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + cancelExpectation.fulfill() + } + wait(for: [cancelExpectation], timeout: 0.5) + } + + func testVeryShortStuckDragTimeout() { + // Set a very short timeout for testing (not recommended in production) + generator.stuckDragTimeout = 0.5 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Wait longer than the timeout without any updates + // The watchdog should auto-release the drag + let timeoutExpectation = XCTestExpectation(description: "Watchdog timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + timeoutExpectation.fulfill() + } + wait(for: [timeoutExpectation], timeout: 3.0) + + // After auto-release, updates should be ignored + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + // endDrag should be a no-op since already released + XCTAssertNoThrow(generator.endDrag()) + } + + func testActivityPreventsWatchdogTimeout() { + // Set a short timeout + generator.stuckDragTimeout = 1.5 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Keep sending updates synchronously with small delays to prevent timeout + // Total time: 8 * 0.15 = 1.2s which is less than 1.5s timeout + for i in 1...8 { + generator.updateDrag(deltaX: CGFloat(i), deltaY: CGFloat(i)) + + let delayExp = XCTestExpectation(description: "Delay \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + delayExp.fulfill() + } + wait(for: [delayExp], timeout: 0.5) + } + + // Should still be able to end normally (not auto-released) + XCTAssertNoThrow(generator.endDrag()) + } + + func testDoubleStartWithUpdatesInBetween() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Do some updates + generator.updateDrag(deltaX: 10.0, deltaY: 10.0) + generator.updateDrag(deltaX: 20.0, deltaY: 20.0) + + // Start second drag (should cancel first and reset state) + generator.startDrag(at: CGPoint(x: 500, y: 500)) + + let secondExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondExpectation.fulfill() + } + wait(for: [secondExpectation], timeout: 0.5) + + // New updates should work from the new starting position + XCTAssertNoThrow(generator.updateDrag(deltaX: 5.0, deltaY: 5.0)) + + generator.endDrag() + } } diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 3c8f3b3..c2c9755 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -2143,4 +2143,214 @@ final class MultitouchManagerTests: XCTestCase { wait(for: [expectation], timeout: 10.0) manager.stop() } + + // MARK: - Force Release Stuck Drag Tests + + func testForceReleaseStuckDragResetsGestureState() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + + let startExpectation = XCTestExpectation(description: "Gesture started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isInThreeFingerGesture) + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + let releaseExpectation = XCTestExpectation(description: "Force release complete") + DispatchQueue.main.async { + XCTAssertFalse(manager.isInThreeFingerGesture) + releaseExpectation.fulfill() + } + wait(for: [releaseExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragResetsDraggingState() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + var config = GestureConfiguration() + config.middleDragEnabled = true + manager.updateConfiguration(config) + + manager.start() + + // Enter dragging state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let startExpectation = XCTestExpectation(description: "Dragging started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isActivelyDragging) + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + let releaseExpectation = XCTestExpectation(description: "Force release complete") + DispatchQueue.main.async { + XCTAssertFalse(manager.isActivelyDragging) + XCTAssertFalse(manager.isInThreeFingerGesture) + releaseExpectation.fulfill() + } + wait(for: [releaseExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragWhenNotDragging() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + + // Force release when not in any gesture state - should not crash + XCTAssertFalse(manager.isInThreeFingerGesture) + XCTAssertFalse(manager.isActivelyDragging) + + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + // State should still be false + XCTAssertFalse(manager.isInThreeFingerGesture) + XCTAssertFalse(manager.isActivelyDragging) + + manager.stop() + } + + func testForceReleaseStuckDragMultipleTimes() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup complete") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + // Force release multiple times - should not crash + for _ in 1...3 { + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + } + + manager.stop() + } + + func testForceReleaseStuckDragResetsGestureRecognizer() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup complete") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + // Should be able to start a new gesture after force release + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.6, y: 0.6)) + + let newGestureExpectation = XCTestExpectation(description: "New gesture started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isInThreeFingerGesture) + newGestureExpectation.fulfill() + } + wait(for: [newGestureExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragWhileDisabled() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + manager.toggleEnabled() // Disable + + XCTAssertFalse(manager.isEnabled) + + // Force release when disabled - should not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + manager.stop() + } + + func testForceReleaseStuckDragWhenStopped() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + // Don't start the manager + + // Force release when stopped - should not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + } + + func testForceReleaseAfterNormalEndDrag() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Start and end drag normally + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + manager.gestureRecognizerDidEndDragging(recognizer) + + let endExpectation = XCTestExpectation(description: "Normal end") + DispatchQueue.main.async { + XCTAssertFalse(manager.isActivelyDragging) + endExpectation.fulfill() + } + wait(for: [endExpectation], timeout: 1.0) + + // Force release after normal end - should be no-op but not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + manager.stop() + } } diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index ccc4d06..0d06a79 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -260,6 +260,17 @@ class MenuBarController: NSObject { )) submenu.addItem(NSMenuItem.separator()) + + // Emergency release for stuck drags + let forceReleaseItem = NSMenuItem( + title: "Force Release Stuck Drag", + action: #selector(forceReleaseStuckDrag), + keyEquivalent: "" + ) + forceReleaseItem.target = self + submenu.addItem(forceReleaseItem) + + submenu.addItem(NSMenuItem.separator()) // Telemetry section header let telemetryHeader = NSMenuItem( @@ -594,6 +605,10 @@ class MenuBarController: NSObject { buildMenu() NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } + + @objc func forceReleaseStuckDrag() { + multitouchManager?.forceReleaseStuckDrag() + } @objc func toggleLaunchAtLogin() { preferences.launchAtLogin.toggle()