From 0ac327096afd658b75dfedbbdd1d0b478093e728 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:18:22 -0800 Subject: [PATCH 1/3] feat: add visual feedback when Force Release Stuck Drag is clicked The menu item is a one-shot action (not a toggle), so there's no checkmark. Added a brief flash of the status bar icon to confirm the action was triggered. --- MiddleDrag/UI/MenuBarController.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 0d06a79..faf8092 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -608,6 +608,30 @@ class MenuBarController: NSObject { @objc func forceReleaseStuckDrag() { multitouchManager?.forceReleaseStuckDrag() + + // Provide visual feedback that the action was triggered + // Flash the status bar icon briefly + flashStatusBarIcon() + } + + /// Flash the status bar icon to provide visual feedback for actions + private func flashStatusBarIcon() { + guard let button = statusItem?.button else { return } + + // Store original image + let originalImage = button.image + + // Flash to a highlighted state (use template rendering) + if let image = originalImage { + let highlightedImage = image.copy() as? NSImage + highlightedImage?.isTemplate = false // Make it appear "active" + button.image = highlightedImage + } + + // Restore after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + button.image = originalImage + } } @objc func toggleLaunchAtLogin() { From 6b968aceb52d6f5ed02a9efe606b47882460c55b Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:18:22 -0800 Subject: [PATCH 2/3] feat: add visual feedback when Force Release Stuck Drag is clicked The menu item is a one-shot action (not a toggle), so there's no checkmark. Added alpha animation on the status bar icon to confirm the action was triggered. Uses NSAnimationContext for smooth animation, consistent with existing updateStatusIcon pattern. Fixes: - isTemplate approach didn't produce visible change - Race condition with rapid clicks (alpha animation is self-contained) - Inconsistent animation pattern --- MiddleDrag/UI/MenuBarController.swift | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index faf8092..ab278d3 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -610,27 +610,23 @@ class MenuBarController: NSObject { multitouchManager?.forceReleaseStuckDrag() // Provide visual feedback that the action was triggered - // Flash the status bar icon briefly flashStatusBarIcon() } /// Flash the status bar icon to provide visual feedback for actions + /// Uses alpha animation consistent with updateStatusIcon pattern private func flashStatusBarIcon() { guard let button = statusItem?.button else { return } - // Store original image - let originalImage = button.image - - // Flash to a highlighted state (use template rendering) - if let image = originalImage { - let highlightedImage = image.copy() as? NSImage - highlightedImage?.isTemplate = false // Make it appear "active" - button.image = highlightedImage - } - - // Restore after a brief delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - button.image = originalImage + // Use alpha animation for visual feedback (consistent with updateStatusIcon) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + button.animator().alphaValue = 0.3 + } completionHandler: { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + button.animator().alphaValue = 1.0 + } } } From a0a8bac8ae4ac51daceaf4a88b187587d3013e6d Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:46:53 -0800 Subject: [PATCH 3/3] test: add tests for thread safety fixes and forceReleaseStuckDrag MouseEventGeneratorTests: - testConcurrentStartDragDoesNotCrash: exercises atomic dragGeneration - testConcurrentStartAndCancelDoesNotCrash: interleaved start/cancel - testStartDragDuringWatchdogTimeoutWindow: verifies new drag isn't affected by old watchdog's forceRelease (generation check works) - testRapidDragCyclesWithWatchdog: rapid cycles with watchdog enabled - testForceMiddleMouseUpDoesNotCrash: new unconditional UP method - testForceMiddleMouseUpMultipleTimes: rapid calls are safe - testForceMiddleMouseUpAfterNormalEnd: safe to call post-drag MenuBarControllerTests: - testForceReleaseStuckDragDoesNotCrash: basic safety - testForceReleaseStuckDragMultipleTimes: rapid calls are safe - testForceReleaseStuckDragWhenStopped: safe when manager stopped - testForceReleaseStuckDragSelectorExists: menu item wiring --- .../MenuBarControllerTests.swift | 32 ++++ .../MouseEventGeneratorTests.swift | 149 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift index 75c5576..2d4a3ae 100644 --- a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift +++ b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift @@ -578,4 +578,36 @@ import XCTest unsafe manager.stop() } + + // MARK: - Force Release Stuck Drag Tests + + func testForceReleaseStuckDragDoesNotCrash() { + unsafe manager.start() + + // Calling force release when not dragging should be safe + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + + unsafe manager.stop() + } + + func testForceReleaseStuckDragMultipleTimes() { + unsafe manager.start() + + // Calling force release multiple times rapidly should be safe + for _ in 0..<5 { + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + } + + unsafe manager.stop() + } + + func testForceReleaseStuckDragWhenStopped() { + // Controller should handle force release even when manager is stopped + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + } + + func testForceReleaseStuckDragSelectorExists() { + let selector = #selector(MenuBarController.forceReleaseStuckDrag) + unsafe XCTAssertTrue(controller.responds(to: selector)) + } } diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 7b345a6..35475ce 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -808,4 +808,153 @@ final class MouseEventGeneratorTests: XCTestCase { generator.endDrag() } + + // MARK: - Thread Safety Tests + + func testConcurrentStartDragDoesNotCrash() { + // Test that rapid concurrent startDrag calls don't cause crashes + // This exercises the atomic dragGeneration increment + let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent) + let group = DispatchGroup() + + for i in 0..<10 { + group.enter() + concurrentQueue.async { + self.generator.startDrag(at: CGPoint(x: CGFloat(i * 10), y: CGFloat(i * 10))) + group.leave() + } + } + + let waitResult = group.wait(timeout: .now() + 2.0) + XCTAssertEqual(waitResult, .success, "Concurrent startDrag calls should complete without deadlock") + + // Clean up + generator.cancelDrag() + } + + func testConcurrentStartAndCancelDoesNotCrash() { + // Test thread safety of start/cancel interleaving + let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent) + let group = DispatchGroup() + + for i in 0..<20 { + group.enter() + concurrentQueue.async { + if i % 2 == 0 { + self.generator.startDrag(at: CGPoint(x: CGFloat(i), y: CGFloat(i))) + } else { + self.generator.cancelDrag() + } + group.leave() + } + } + + let waitResult = group.wait(timeout: .now() + 2.0) + XCTAssertEqual(waitResult, .success, "Concurrent start/cancel should complete without deadlock") + + // Clean up + generator.cancelDrag() + } + + func testStartDragDuringWatchdogTimeoutWindow() { + // Test that starting a new drag during the watchdog timeout window + // doesn't cause the old watchdog to interfere with the new drag + generator.stuckDragTimeout = 0.3 // Very short timeout + + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Wait until just before timeout would trigger + let nearTimeoutExpectation = XCTestExpectation(description: "Near timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + nearTimeoutExpectation.fulfill() + } + wait(for: [nearTimeoutExpectation], timeout: 0.5) + + // Start second drag right before timeout - this should increment generation + // and cause any pending watchdog release to abort + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + // Wait past the original timeout + let pastTimeoutExpectation = XCTestExpectation(description: "Past original timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + pastTimeoutExpectation.fulfill() + } + wait(for: [pastTimeoutExpectation], timeout: 0.5) + + // The new drag should still be active (updates should work) + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + // Clean up + generator.endDrag() + } + + func testRapidDragCyclesWithWatchdog() { + // Test rapid start/end cycles with watchdog enabled + generator.stuckDragTimeout = 0.5 + + for i in 0..<5 { + generator.startDrag(at: CGPoint(x: CGFloat(i * 100), y: CGFloat(i * 100))) + generator.updateDrag(deltaX: 10.0, deltaY: 10.0) + generator.endDrag() + + // Small delay between cycles + let delayExp = XCTestExpectation(description: "Delay \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + delayExp.fulfill() + } + wait(for: [delayExp], timeout: 0.2) + } + + // All cycles should complete without crashes or stuck state + XCTAssertNoThrow(generator.cancelDrag()) + } + + func testForceMiddleMouseUpDoesNotCrash() { + // Test that forceMiddleMouseUp can be called safely + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + + // Also test during active drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExp = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + + // After force release, updates should be ignored + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + } + + func testForceMiddleMouseUpMultipleTimes() { + // Test that calling forceMiddleMouseUp multiple times rapidly is safe + for _ in 0..<5 { + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + } + } + + func testForceMiddleMouseUpAfterNormalEnd() { + // Start and end a drag normally + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExp = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + generator.endDrag() + + let endExp = XCTestExpectation(description: "Drag ended") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + endExp.fulfill() + } + wait(for: [endExp], timeout: 0.5) + + // Force release after normal end should still be safe + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + } }