diff --git a/MiddleDrag.xcodeproj/project.pbxproj b/MiddleDrag.xcodeproj/project.pbxproj index 8e9a095..3991501 100644 --- a/MiddleDrag.xcodeproj/project.pbxproj +++ b/MiddleDrag.xcodeproj/project.pbxproj @@ -70,8 +70,10 @@ Models/GestureModels.swift, Models/TouchModels.swift, UI/AlertHelper.swift, + UI/HotKeyRecorderView.swift, UI/MenuBarController.swift, Utilities/AnalyticsManager.swift, + Utilities/GlobalHotKeyManager.swift, Utilities/LaunchAtLoginManager.swift, Utilities/PreferencesManager.swift, Utilities/ScreenHelper.swift, @@ -90,6 +92,8 @@ MiddleDragTests/DeviceMonitorTests.swift, MiddleDragTests/GestureModelsTests.swift, MiddleDragTests/GestureRecognizerTests.swift, + MiddleDragTests/GlobalHotKeyManagerTests.swift, + MiddleDragTests/HotKeyRecorderViewTests.swift, MiddleDragTests/LaunchAtLoginManagerTests.swift, MiddleDragTests/MenuBarControllerTests.swift, MiddleDragTests/Mocks/MockDeviceMonitor.swift, @@ -109,7 +113,6 @@ membershipExceptions = ( Debug.xcconfig, Release.xcconfig, - Secrets.xcconfig, ); target = 1A0000011 /* MiddleDrag */; }; @@ -131,6 +134,8 @@ MiddleDragTests/DeviceMonitorTests.swift, MiddleDragTests/GestureModelsTests.swift, MiddleDragTests/GestureRecognizerTests.swift, + MiddleDragTests/GlobalHotKeyManagerTests.swift, + MiddleDragTests/HotKeyRecorderViewTests.swift, MiddleDragTests/LaunchAtLoginManagerTests.swift, MiddleDragTests/MenuBarControllerTests.swift, MiddleDragTests/Mocks/MockDeviceMonitor.swift, @@ -145,8 +150,10 @@ Models/GestureModels.swift, Models/TouchModels.swift, UI/AlertHelper.swift, + UI/HotKeyRecorderView.swift, UI/MenuBarController.swift, Utilities/AnalyticsManager.swift, + Utilities/GlobalHotKeyManager.swift, Utilities/LaunchAtLoginManager.swift, Utilities/PreferencesManager.swift, Utilities/ScreenHelper.swift, diff --git a/MiddleDrag/AppDelegate.swift b/MiddleDrag/AppDelegate.swift index f0e485d..8fbf28d 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -1,4 +1,5 @@ import Cocoa +import Carbon.HIToolbox import MiddleDragCore /// Main application delegate @@ -18,6 +19,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var preferences: UserPreferences! private var accessibilityMonitor: AccessibilityMonitor? + + private var toggleHotKeyID: UInt32 = 0 + private var menuBarHotKeyID: UInt32 = 0 // MARK: - Application Lifecycle @@ -111,6 +115,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) Log.info("Menu bar controller initialized", category: .app) + // Register global hotkeys + registerHotKeys() + // Set up notification observers setupNotifications() @@ -160,6 +167,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { return true } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + menuBarController?.showMenuBarIcon() + return false + } + // MARK: - Setup private func setupNotifications() { @@ -177,6 +189,32 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil ) } + + /// Register (or re-register) global hotkeys from current preferences + private func registerHotKeys() { + // Unregister existing + if toggleHotKeyID != 0 { + GlobalHotKeyManager.shared.unregister(id: toggleHotKeyID) + } + if menuBarHotKeyID != 0 { + GlobalHotKeyManager.shared.unregister(id: menuBarHotKeyID) + } + + // Register from preferences + toggleHotKeyID = GlobalHotKeyManager.shared.register( + keyCode: preferences.toggleHotKey.keyCode, + modifiers: preferences.toggleHotKey.carbonModifiers + ) { [weak self] in + self?.menuBarController?.toggleEnabled() + } + + menuBarHotKeyID = GlobalHotKeyManager.shared.register( + keyCode: preferences.menuBarHotKey.keyCode, + modifiers: preferences.menuBarHotKey.carbonModifiers + ) { [weak self] in + self?.menuBarController?.toggleMenuBarVisibility() + } + } // MARK: - Notification Handlers @@ -185,6 +223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { preferences = newPreferences PreferencesManager.shared.savePreferences(preferences) multitouchManager.updateConfiguration(preferences.gestureConfig) + registerHotKeys() Log.info("Preferences updated", category: .app) } } diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 77eadd8..a9b58e2 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -870,9 +870,9 @@ extension MultitouchManager: DeviceMonitorDelegate { gestureQueue.async { [weak self] in if let data = touchData { - data.withUnsafeBytes { rawBuffer in + unsafe data.withUnsafeBytes { rawBuffer in guard let baseAddress = rawBuffer.baseAddress else { return } - let buffer = UnsafeMutableRawPointer(mutating: baseAddress) + let buffer = unsafe UnsafeMutableRawPointer(mutating: baseAddress) unsafe self?.gestureRecognizer.processTouches( buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) } diff --git a/MiddleDrag/MiddleDragTests/AlertHelperTests.swift b/MiddleDrag/MiddleDragTests/AlertHelperTests.swift index 3a8942a..c411678 100644 --- a/MiddleDrag/MiddleDragTests/AlertHelperTests.swift +++ b/MiddleDrag/MiddleDragTests/AlertHelperTests.swift @@ -46,15 +46,15 @@ final class AlertHelperTests: XCTestCase { AlertHelper.presenter = mockPresenter // Save and replace SystemGestureHelper settings provider - originalSettingsProvider = SystemGestureHelper.settingsProvider + originalSettingsProvider = unsafe SystemGestureHelper.settingsProvider mockSettingsProvider = MockTrackpadSettingsProvider() - SystemGestureHelper.settingsProvider = mockSettingsProvider + unsafe SystemGestureHelper.settingsProvider = mockSettingsProvider } override func tearDown() { // Restore originals AlertHelper.presenter = originalPresenter - SystemGestureHelper.settingsProvider = originalSettingsProvider + unsafe SystemGestureHelper.settingsProvider = originalSettingsProvider mockPresenter = nil mockSettingsProvider = nil super.tearDown() diff --git a/MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift b/MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift index 68af990..6df88b2 100644 --- a/MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift +++ b/MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift @@ -80,7 +80,7 @@ import XCTest } func testStartStopStartDoesNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Should be able to restart the monitor unsafe XCTAssertNoThrow(monitor.start()) unsafe XCTAssertNoThrow(monitor.stop()) @@ -122,7 +122,7 @@ import XCTest // MARK: - Multiple Instance Tests func testMultipleInstancesDoNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Create multiple monitors - only first should own global reference let monitor2 = unsafe DeviceMonitor() let monitor3 = unsafe DeviceMonitor() @@ -208,7 +208,7 @@ import XCTest } func testRapidStartStopCyclesDoNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Simulates the race condition scenario where rapid restart cycles // could cause the framework's internal thread to access deallocated resources. // The fix adds delays to prevent this, so rapid cycles should be safe. @@ -222,7 +222,7 @@ import XCTest } func testStopSeparatesCallbackUnregistrationFromDeviceStop() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // This test exercises the code path where: // 1. Callbacks are unregistered first (MTUnregisterContactFrameCallback) // 2. A delay occurs (Thread.sleep) @@ -241,7 +241,7 @@ import XCTest } func testConcurrentStopDoesNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Test that even if something tries to access the monitor during stop, // it doesn't crash. This simulates what happens when the framework's // internal thread is still processing while we stop. @@ -287,7 +287,7 @@ import XCTest } func testRapidRestartCyclesWithDelayDoNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Simulates the exact scenario from the bug report: // Rapid restart cycles during connectivity changes causing // gDeviceMonitor to become nil while callbacks are still in-flight. @@ -318,7 +318,7 @@ import XCTest } func testConcurrentStartStopDoesNotCrash() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Test that concurrent start/stop operations on the same instance don't crash. // NOTE: Concurrent start/stop on the same instance may leave it in an inconsistent // state, but it should NOT crash due to the locking mechanism protecting global state. @@ -353,7 +353,7 @@ import XCTest } func testMultipleMonitorCreationDuringCleanup() throws { - try requireUnsafeMultitouchTestsEnabled() + try unsafe requireUnsafeMultitouchTestsEnabled() // Test that creating new monitors while the old one is being cleaned up // doesn't cause a crash. This tests the gPendingCleanup mechanism. unsafe monitor.start() diff --git a/MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift b/MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift index 2db2382..a8d29c9 100644 --- a/MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift +++ b/MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift @@ -57,7 +57,7 @@ final class GestureRecognizerTests: XCTestCase { let count = touches.count guard count > 0 else { // Non-null placeholder; processTouches won't dereference when count == 0. - return (UnsafeMutableRawPointer(bitPattern: 1)!, 0, {}) + return unsafe (UnsafeMutableRawPointer(bitPattern: 1)!, 0, {}) } let pointer = UnsafeMutablePointer.allocate(capacity: count) diff --git a/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift b/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift new file mode 100644 index 0000000..aeb80b2 --- /dev/null +++ b/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift @@ -0,0 +1,344 @@ +import XCTest +import Carbon.HIToolbox + +@testable import MiddleDragCore + +@MainActor @unsafe final class GlobalHotKeyManagerTests: XCTestCase { + + // MARK: - carbonModifiers Tests + + func testCarbonModifiersCommand() { + let result = GlobalHotKeyManager.carbonModifiers(from: .command) + XCTAssertEqual(result, UInt32(cmdKey)) + } + + func testCarbonModifiersOption() { + let result = GlobalHotKeyManager.carbonModifiers(from: .option) + XCTAssertEqual(result, UInt32(optionKey)) + } + + func testCarbonModifiersShift() { + let result = GlobalHotKeyManager.carbonModifiers(from: .shift) + XCTAssertEqual(result, UInt32(shiftKey)) + } + + func testCarbonModifiersControl() { + let result = GlobalHotKeyManager.carbonModifiers(from: .control) + XCTAssertEqual(result, UInt32(controlKey)) + } + + func testCarbonModifiersCombinedCommandShift() { + let result = GlobalHotKeyManager.carbonModifiers(from: [.command, .shift]) + XCTAssertEqual(result, UInt32(cmdKey) | UInt32(shiftKey)) + } + + func testCarbonModifiersCombinedOptionControl() { + let result = GlobalHotKeyManager.carbonModifiers(from: [.option, .control]) + XCTAssertEqual(result, UInt32(optionKey) | UInt32(controlKey)) + } + + func testCarbonModifiersCombinedCommandOption() { + let result = GlobalHotKeyManager.carbonModifiers(from: [.command, .option]) + XCTAssertEqual(result, UInt32(cmdKey) | UInt32(optionKey)) + } + + func testCarbonModifiersAllFour() { + let result = GlobalHotKeyManager.carbonModifiers(from: [.command, .option, .shift, .control]) + let expected = UInt32(cmdKey) | UInt32(optionKey) | UInt32(shiftKey) | UInt32(controlKey) + XCTAssertEqual(result, expected) + } + + func testCarbonModifiersEmpty() { + let result = GlobalHotKeyManager.carbonModifiers(from: []) + XCTAssertEqual(result, 0) + } + + func testCarbonModifiersIgnoresCapsLock() { + let result = GlobalHotKeyManager.carbonModifiers(from: .capsLock) + XCTAssertEqual(result, 0) + } + + func testCarbonModifiersIgnoresFunction() { + let result = GlobalHotKeyManager.carbonModifiers(from: .function) + XCTAssertEqual(result, 0) + } + + func testCarbonModifiersIgnoresNumericPad() { + let result = GlobalHotKeyManager.carbonModifiers(from: .numericPad) + XCTAssertEqual(result, 0) + } + + func testCarbonModifiersMixedValidAndInvalid() { + // .capsLock should be ignored, .command should pass through + let result = GlobalHotKeyManager.carbonModifiers(from: [.capsLock, .command]) + XCTAssertEqual(result, UInt32(cmdKey)) + } + + func testCarbonModifiersIdempotent() { + // Calling with same flags twice should produce identical results + let flags: NSEvent.ModifierFlags = [.command, .shift, .option] + let result1 = GlobalHotKeyManager.carbonModifiers(from: flags) + let result2 = GlobalHotKeyManager.carbonModifiers(from: flags) + XCTAssertEqual(result1, result2) + } + + func testCarbonModifiersTripleCombination() { + let result = GlobalHotKeyManager.carbonModifiers(from: [.command, .shift, .option]) + let expected = UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + XCTAssertEqual(result, expected) + } + + // MARK: - Singleton Tests + + func testSharedInstanceIsSingleton() { + let a = GlobalHotKeyManager.shared + let b = GlobalHotKeyManager.shared + XCTAssertTrue(a === b) + } + + func testSharedInstanceIsNotNil() { + XCTAssertNotNil(GlobalHotKeyManager.shared) + } + + // MARK: - Register / Unregister Tests + + func testRegisterDoesNotCrash() { + // Carbon RegisterEventHotKey may fail in CI (no window server), + // but the method itself should not crash + let id = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_F), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + // Clean up regardless of success + if id != 0 { + GlobalHotKeyManager.shared.unregister(id: id) + } + } + + func testRegisterMultipleHotkeysReturnsDifferentIDs() { + let id1 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_J), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + let id2 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_K), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + // If both succeeded, IDs should differ + if id1 != 0 && id2 != 0 { + XCTAssertNotEqual(id1, id2) + } + + // Clean up + if id1 != 0 { GlobalHotKeyManager.shared.unregister(id: id1) } + if id2 != 0 { GlobalHotKeyManager.shared.unregister(id: id2) } + } + + func testRegisterReturnsMonotonicallyIncreasingIDs() { + let id1 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_N), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + let id2 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_O), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + // IDs should be monotonically increasing (even if registration fails, nextID advances) + if id1 != 0 && id2 != 0 { + XCTAssertGreaterThan(id2, id1) + } + + if id1 != 0 { GlobalHotKeyManager.shared.unregister(id: id1) } + if id2 != 0 { GlobalHotKeyManager.shared.unregister(id: id2) } + } + + func testUnregisterInvalidIDDoesNotCrash() { + XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: 99999)) + } + + func testUnregisterZeroIDDoesNotCrash() { + // 0 is the failure return value from register + XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: 0)) + } + + func testUnregisterMaxUInt32DoesNotCrash() { + XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: UInt32.max)) + } + + func testDoubleUnregisterDoesNotCrash() { + let id = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_L), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + if id != 0 { + GlobalHotKeyManager.shared.unregister(id: id) + // Second unregister of same ID should be safe + XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: id)) + } + } + + func testRegisterAndUnregisterCycle() { + // Register, unregister, re-register should work cleanly + let id1 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_P), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + if id1 != 0 { + GlobalHotKeyManager.shared.unregister(id: id1) + } + + // Re-register same combo after unregister + let id2 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_P), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + // New registration should get a new ID + if id1 != 0 && id2 != 0 { + XCTAssertNotEqual(id1, id2) + } + + if id2 != 0 { GlobalHotKeyManager.shared.unregister(id: id2) } + } + + func testRegisterWithDifferentModifiers() { + // Same key code with different modifiers should both succeed + let id1 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_Q), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + let id2 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_Q), + modifiers: UInt32(cmdKey) | UInt32(controlKey) | UInt32(optionKey) + ) {} + + if id1 != 0 && id2 != 0 { + XCTAssertNotEqual(id1, id2) + } + + if id1 != 0 { GlobalHotKeyManager.shared.unregister(id: id1) } + if id2 != 0 { GlobalHotKeyManager.shared.unregister(id: id2) } + } + + // MARK: - Handler Tests + + func testRegisterStoresHandlerWithoutInvoking() { + var handlerCalled = false + let id = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_G), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) { + handlerCalled = true + } + + // Handler should not be invoked on registration + XCTAssertFalse(handlerCalled) + + if id != 0 { GlobalHotKeyManager.shared.unregister(id: id) } + } + + func testUnregisterClearsHandler() { + var handlerCalled = false + let id = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_H), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) { + handlerCalled = true + } + + if id != 0 { + GlobalHotKeyManager.shared.unregister(id: id) + } + + // After unregister, handler should still not have been called + XCTAssertFalse(handlerCalled) + } + + func testRegisterWithEmptyHandler() { + // An empty closure should still be valid + let id = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_I), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) { /* no-op */ } + + if id != 0 { GlobalHotKeyManager.shared.unregister(id: id) } + } + + // MARK: - Invalidate Tests + + // Note: We cannot fully test invalidate() on the shared singleton without + // breaking other tests. Instead we verify it doesn't crash. + + func testInvalidateDoesNotCrash() { + // Register some hotkeys first + let id1 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_R), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + let id2 = GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_S), + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + + // invalidate should unregister all and remove the event handler + XCTAssertNoThrow(GlobalHotKeyManager.shared.invalidate()) + + // Unregister after invalidate should be safe (no-ops) + if id1 != 0 { XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: id1)) } + if id2 != 0 { XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: id2)) } + } + + func testDoubleInvalidateDoesNotCrash() { + XCTAssertNoThrow(GlobalHotKeyManager.shared.invalidate()) + XCTAssertNoThrow(GlobalHotKeyManager.shared.invalidate()) + } + + func testInvalidateWithNoRegisteredHotkeys() { + // Invalidate when nothing is registered should be safe + XCTAssertNoThrow(GlobalHotKeyManager.shared.invalidate()) + } + + // MARK: - Rapid Registration Stress Tests + + func testRapidRegisterUnregisterCycles() { + // Stress test: rapid register/unregister shouldn't crash + for i: UInt32 in 0..<10 { + let keyCode = UInt32(kVK_ANSI_A) + (i % 26) + let id = GlobalHotKeyManager.shared.register( + keyCode: keyCode, + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) + ) {} + if id != 0 { + GlobalHotKeyManager.shared.unregister(id: id) + } + } + } + + func testRegisterManyHotkeysAtOnce() { + var ids: [UInt32] = [] + + // Register 5 different hotkeys simultaneously + for i: UInt32 in 0..<5 { + let keyCode = UInt32(kVK_ANSI_A) + i + let id = GlobalHotKeyManager.shared.register( + keyCode: keyCode, + modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) | UInt32(controlKey) + ) {} + ids.append(id) + } + + // All non-zero IDs should be unique + let nonZeroIDs = ids.filter { $0 != 0 } + XCTAssertEqual(nonZeroIDs.count, Set(nonZeroIDs).count, "All IDs should be unique") + + // Clean up + for id in ids where id != 0 { + GlobalHotKeyManager.shared.unregister(id: id) + } + } +} diff --git a/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift b/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift new file mode 100644 index 0000000..857e1ad --- /dev/null +++ b/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift @@ -0,0 +1,386 @@ +import XCTest +import Carbon.HIToolbox + +@testable import MiddleDragCore + +@MainActor @unsafe final class HotKeyRecorderViewTests: XCTestCase { + + // MARK: - Helper + + private func makeBinding( + keyCode: Int = kVK_ANSI_E, + modifiers: UInt32 = UInt32(cmdKey) + ) -> HotKeyBinding { + HotKeyBinding(keyCode: UInt32(keyCode), carbonModifiers: modifiers) + } + + private func makeRecorder( + keyCode: Int = kVK_ANSI_E, + modifiers: UInt32 = UInt32(cmdKey) + ) -> HotKeyRecorderView { + unsafe HotKeyRecorderView(binding: makeBinding(keyCode: keyCode, modifiers: modifiers)) + } + + // MARK: - Initialization Tests + + func testInitStoresBinding() { + let binding = unsafe makeBinding() + let recorder = HotKeyRecorderView(binding: binding) + XCTAssertEqual(recorder.binding, binding) + } + + func testInitShowsBindingDisplayString() { + let recorder = unsafe makeRecorder() + // Should show key name, not "Press a key…" + XCTAssertFalse(recorder.title.contains("Press a key")) + } + + func testInitSetsBezelStyle() { + let recorder = unsafe makeRecorder() + XCTAssertEqual(recorder.bezelStyle, .recessed) + } + + func testInitSetsButtonType() { + let recorder = unsafe makeRecorder() + // momentaryPushIn - verifying it was set by checking it doesn't crash + XCTAssertNotNil(recorder) + } + + func testInitSetsBordered() { + let recorder = unsafe makeRecorder() + XCTAssertTrue(recorder.isBordered) + } + + func testInitSetsMonospacedFont() { + let recorder = unsafe makeRecorder() + XCTAssertNotNil(recorder.font) + } + + func testInitSetsTargetToSelf() { + let recorder = unsafe makeRecorder() + XCTAssertTrue(recorder.target as AnyObject === recorder) + } + + func testInitSetsAction() { + let recorder = unsafe makeRecorder() + XCTAssertNotNil(recorder.action) + } + + func testInitWithDifferentBindings() { + let bindings = unsafe [ + makeBinding(keyCode: kVK_ANSI_A, modifiers: UInt32(cmdKey)), + makeBinding(keyCode: kVK_ANSI_M, modifiers: UInt32(cmdKey) | UInt32(shiftKey)), + makeBinding(keyCode: kVK_ANSI_Z, modifiers: UInt32(optionKey) | UInt32(controlKey)), + ] + + for binding in bindings { + let recorder = HotKeyRecorderView(binding: binding) + XCTAssertEqual(recorder.binding, binding) + } + } + + // MARK: - Binding Update Tests + + func testBindingDidSetUpdatesTitle() { + let recorder = unsafe makeRecorder(keyCode: kVK_ANSI_A) + let titleBefore = recorder.title + + recorder.binding = unsafe makeBinding(keyCode: kVK_ANSI_B, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + XCTAssertNotEqual(recorder.title, titleBefore) + } + + func testBindingEquality() { + let a = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let b = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + XCTAssertEqual(a, b) + } + + func testBindingInequalityByKeyCode() { + let a = unsafe makeBinding(keyCode: kVK_ANSI_E) + let b = unsafe makeBinding(keyCode: kVK_ANSI_M) + XCTAssertNotEqual(a, b) + } + + func testBindingInequalityByModifiers() { + let a = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey)) + let b = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(shiftKey)) + XCTAssertNotEqual(a, b) + } + + func testBindingCodable() throws { + let original = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(HotKeyBinding.self, from: data) + XCTAssertEqual(original, decoded) + } + + func testMultipleBindingUpdates() { + let recorder = unsafe makeRecorder() + + let bindings = unsafe [ + makeBinding(keyCode: kVK_ANSI_A), + makeBinding(keyCode: kVK_ANSI_B, modifiers: UInt32(shiftKey)), + makeBinding(keyCode: kVK_ANSI_C, modifiers: UInt32(cmdKey) | UInt32(optionKey)), + ] + + for binding in bindings { + recorder.binding = binding + XCTAssertEqual(recorder.binding, binding) + } + } + + // MARK: - startRecording Tests + + func testStartRecordingChangesTitle() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + XCTAssertEqual(recorder.title, "Press a key…") + } + + func testStartRecordingIdempotent() { + let recorder = unsafe makeRecorder() + + // Click twice - guard prevents double installation + recorder.performClick(nil) + recorder.performClick(nil) + + // Should still show recording state (not crashed) + XCTAssertEqual(recorder.title, "Press a key…") + + // Single cancel should fully clean up + recorder.cancelRecording() + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + // MARK: - cancelRecording Tests + + func testCancelRecordingWhenNotRecording() { + let recorder = unsafe makeRecorder() + // Should be safe to call when not recording (no-op) + XCTAssertNoThrow(recorder.cancelRecording()) + } + + func testCancelRecordingStopsRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + XCTAssertEqual(recorder.title, "Press a key…") + + recorder.cancelRecording() + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testCancelRecordingPreservesOriginalBinding() { + let original = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let recorder = HotKeyRecorderView(binding: original) + + recorder.performClick(nil) + recorder.cancelRecording() + + XCTAssertEqual(recorder.binding, original) + } + + func testDoubleCancelRecordingDoesNotCrash() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + recorder.cancelRecording() + XCTAssertNoThrow(recorder.cancelRecording()) + } + + func testCancelRecordingRestoresTitleToDisplayString() { + let binding = unsafe makeBinding() + let recorder = HotKeyRecorderView(binding: binding) + let expectedTitle = binding.displayString + + recorder.performClick(nil) + recorder.cancelRecording() + + XCTAssertEqual(recorder.title, expectedTitle) + } + + // MARK: - resignFirstResponder Tests + + func testResignFirstResponderStopsRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + XCTAssertEqual(recorder.title, "Press a key…") + + _ = recorder.resignFirstResponder() + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testResignFirstResponderWhenNotRecording() { + let recorder = unsafe makeRecorder() + let result = recorder.resignFirstResponder() + XCTAssertTrue(result) + } + + func testResignFirstResponderPreservesBinding() { + let original = unsafe makeBinding() + let recorder = HotKeyRecorderView(binding: original) + + recorder.performClick(nil) + _ = recorder.resignFirstResponder() + + XCTAssertEqual(recorder.binding, original) + } + + func testResignFirstResponderReturnsTrueAfterRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + let result = recorder.resignFirstResponder() + XCTAssertTrue(result) + } + + // MARK: - onBindingChanged Callback Tests + + func testOnBindingChangedNotCalledOnCancel() { + let recorder = unsafe makeRecorder() + var callbackCalled = false + recorder.onBindingChanged = { _ in callbackCalled = true } + + recorder.performClick(nil) + recorder.cancelRecording() + + XCTAssertFalse(callbackCalled) + } + + func testOnBindingChangedNotCalledOnResignFirstResponder() { + let recorder = unsafe makeRecorder() + var callbackCalled = false + recorder.onBindingChanged = { _ in callbackCalled = true } + + recorder.performClick(nil) + _ = recorder.resignFirstResponder() + + XCTAssertFalse(callbackCalled) + } + + func testOnBindingChangedDefaultIsNil() { + let recorder = unsafe makeRecorder() + XCTAssertNil(recorder.onBindingChanged) + } + + func testOnBindingChangedCanBeSet() { + let recorder = unsafe makeRecorder() + recorder.onBindingChanged = { _ in } + XCTAssertNotNil(recorder.onBindingChanged) + } + + func testOnBindingChangedCanBeCleared() { + let recorder = unsafe makeRecorder() + recorder.onBindingChanged = { _ in } + recorder.onBindingChanged = nil + XCTAssertNil(recorder.onBindingChanged) + } + + // MARK: - invalidate() Tests (deinit safety net) + + func testInvalidateWhenNotRecording() { + let recorder = unsafe makeRecorder() + // Should be safe to call when no monitor is installed + XCTAssertNoThrow(recorder.invalidate()) + } + + func testInvalidateWhileRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + + // Should clean up the monitor + XCTAssertNoThrow(recorder.invalidate()) + } + + func testDoubleInvalidateDoesNotCrash() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + recorder.invalidate() + XCTAssertNoThrow(recorder.invalidate()) + } + + func testInvalidateAfterCancelRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) + recorder.cancelRecording() + // invalidate after cancel should be safe (monitor already removed) + XCTAssertNoThrow(recorder.invalidate()) + } + + // MARK: - Deallocation Safety Tests + + func testDeallocAfterRecordingStartedDoesNotCrash() { + var recorder: HotKeyRecorderView? = unsafe makeRecorder() + recorder?.performClick(nil) + + // Deallocate without calling cancelRecording + // invalidate() in deinit should clean up the monitor + recorder = nil + XCTAssertNil(recorder) + } + + func testDeallocWithoutRecordingDoesNotCrash() { + var recorder: HotKeyRecorderView? = unsafe makeRecorder() + recorder = nil + XCTAssertNil(recorder) + } + + func testDeallocAfterCancelDoesNotCrash() { + var recorder: HotKeyRecorderView? = unsafe makeRecorder() + recorder?.performClick(nil) + recorder?.cancelRecording() + recorder = nil + XCTAssertNil(recorder) + } + + // MARK: - Recording Lifecycle Stress Tests + + func testRapidStartCancelCycles() { + let recorder = unsafe makeRecorder() + + for _ in 0..<20 { + recorder.performClick(nil) + recorder.cancelRecording() + } + + // Should still be in valid state + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testStartRecordingCancelWithResignMixed() { + let recorder = unsafe makeRecorder() + + // Cycle 1: start -> cancel + recorder.performClick(nil) + recorder.cancelRecording() + + // Cycle 2: start -> resignFirstResponder + recorder.performClick(nil) + _ = recorder.resignFirstResponder() + + // Cycle 3: start -> cancel again + recorder.performClick(nil) + recorder.cancelRecording() + + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testBindingUnchangedAfterMultipleCancelCycles() { + let original = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let recorder = HotKeyRecorderView(binding: original) + + for _ in 0..<5 { + recorder.performClick(nil) + recorder.cancelRecording() + } + + XCTAssertEqual(recorder.binding, original) + } + + // MARK: - Frame / Layout Tests + + func testRecorderAcceptsFrame() { + let recorder = unsafe makeRecorder() + recorder.frame = NSRect(x: 0, y: 0, width: 200, height: 24) + XCTAssertEqual(recorder.frame.width, 200) + XCTAssertEqual(recorder.frame.height, 24) + } +} + diff --git a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift index bc641c1..92a77c0 100644 --- a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift +++ b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift @@ -610,4 +610,104 @@ import XCTest let selector = #selector(MenuBarController.forceReleaseStuckDrag) unsafe XCTAssertTrue(controller.responds(to: selector)) } + + // MARK: - Menu Bar Visibility Tests + + func testMenuBarVisibleByDefault() { + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } + + func testHideMenuBarIcon() { + unsafe controller.hideMenuBarIcon() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + } + + func testToggleMenuBarVisibilityHides() { + unsafe XCTAssertTrue(controller.isMenuBarVisible) + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + } + + func testToggleMenuBarVisibilityRestores() { + unsafe controller.hideMenuBarIcon() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } + + func testToggleMenuBarVisibilityRoundTrip() { + unsafe XCTAssertTrue(controller.isMenuBarVisible) + + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } + + func testHideMenuBarIconMultipleTimes() { + // Hiding when already hidden should be safe + unsafe controller.hideMenuBarIcon() + unsafe controller.hideMenuBarIcon() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + } + + func testHideMenuBarIconSelectorExists() { + let selector = #selector(MenuBarController.hideMenuBarIcon) + unsafe XCTAssertTrue(controller.responds(to: selector)) + } + + func testToggleMenuBarVisibilityDoesNotCrash() { + // Rapid toggling should be safe + for _ in 0..<10 { + unsafe XCTAssertNoThrow(controller.toggleMenuBarVisibility()) + } + } + + func testHideMenuBarIconThenBuildMenu() { + unsafe controller.hideMenuBarIcon() + // Building the menu while hidden should not crash + unsafe XCTAssertNoThrow(controller.buildMenu()) + } + + func testToggleMenuBarVisibilityWhileManagerRunning() { + unsafe manager.start() + + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + + unsafe controller.toggleMenuBarVisibility() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + + unsafe manager.stop() + } + + // MARK: - showMenuBarIcon Tests + + func testShowMenuBarIconWhenHidden() { + unsafe controller.hideMenuBarIcon() + unsafe XCTAssertFalse(controller.isMenuBarVisible) + + unsafe controller.showMenuBarIcon() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } + + func testShowMenuBarIconWhenAlreadyVisibleIsNoOp() { + // Already visible by default + unsafe XCTAssertTrue(controller.isMenuBarVisible) + + // Should not crash or change state + unsafe controller.showMenuBarIcon() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } + + func testShowMenuBarIconDoesNotHide() { + // This is the key behavioral test: show can only restore, never hide + unsafe XCTAssertTrue(controller.isMenuBarVisible) + unsafe controller.showMenuBarIcon() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + unsafe controller.showMenuBarIcon() + unsafe XCTAssertTrue(controller.isMenuBarVisible) + } } diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 77abb30..ab39ad4 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -1144,7 +1144,7 @@ final class MouseEventGeneratorTests: XCTestCase { // The new start position should NOT be posAfterFirstDrag (550, 550) // It should be re-seeded from the current cursor position - let distFromOldPos = abs(newStart.x - posAfterFirstDrag.x) + abs(newStart.y - posAfterFirstDrag.y) + _ = abs(newStart.x - posAfterFirstDrag.x) + abs(newStart.y - posAfterFirstDrag.y) // If the position was carried over it would be (550, 550) — check it's different // (unless the cursor happens to be exactly there, which is astronomically unlikely) @@ -1169,7 +1169,7 @@ final class MouseEventGeneratorTests: XCTestCase { let bounds = MouseEventGenerator.globalDisplayBounds // Start near the right edge generator.startDrag(at: CGPoint(x: 100, y: 100)) - let startPos = generator.lastDragPosition + _ = generator.lastDragPosition // Try to drag far beyond the right edge let hugeOvershoot: CGFloat = 50000 diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 80b34b6..b6f40dd 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -675,7 +675,7 @@ final class MultitouchManagerTests: XCTestCase { } func testAttemptDeviceConnectionDeviceMonitorFailureResumesPolling() { - var deviceStartShouldSucceed = false + let deviceStartShouldSucceed = false let manager = MultitouchManager( deviceProviderFactory: { let monitor = unsafe MockDeviceMonitor() diff --git a/MiddleDrag/MiddleDragTests/SystemGestureHelperTests.swift b/MiddleDrag/MiddleDragTests/SystemGestureHelperTests.swift index ed62025..1486723 100644 --- a/MiddleDrag/MiddleDragTests/SystemGestureHelperTests.swift +++ b/MiddleDrag/MiddleDragTests/SystemGestureHelperTests.swift @@ -49,20 +49,20 @@ final class SystemGestureHelperTests: XCTestCase { override func setUp() { super.setUp() // Save originals - originalSettingsProvider = SystemGestureHelper.settingsProvider - originalProcessRunner = SystemGestureHelper.processRunner + originalSettingsProvider = unsafe SystemGestureHelper.settingsProvider + originalProcessRunner = unsafe SystemGestureHelper.processRunner // Create and inject mocks mockSettingsProvider = MockTrackpadSettingsProvider() mockProcessRunner = MockProcessRunner() - SystemGestureHelper.settingsProvider = mockSettingsProvider - SystemGestureHelper.processRunner = mockProcessRunner + unsafe SystemGestureHelper.settingsProvider = mockSettingsProvider + unsafe SystemGestureHelper.processRunner = mockProcessRunner } override func tearDown() { // Restore originals - SystemGestureHelper.settingsProvider = originalSettingsProvider - SystemGestureHelper.processRunner = originalProcessRunner + unsafe SystemGestureHelper.settingsProvider = originalSettingsProvider + unsafe SystemGestureHelper.processRunner = originalProcessRunner mockSettingsProvider = nil mockProcessRunner = nil super.tearDown() diff --git a/MiddleDrag/Models/GestureModels.swift b/MiddleDrag/Models/GestureModels.swift index eb7272d..2001d6e 100644 --- a/MiddleDrag/Models/GestureModels.swift +++ b/MiddleDrag/Models/GestureModels.swift @@ -1,4 +1,6 @@ import Foundation +import AppKit +import Carbon.HIToolbox // MARK: - Gesture State @@ -107,6 +109,98 @@ enum ModifierKeyType: String, Codable, CaseIterable, Sendable { } } +// MARK: - Hot Key Binding + +/// Persistable hotkey binding (virtual key code + modifier flags) +public struct HotKeyBinding: Codable, Equatable, Sendable { + public var keyCode: UInt32 + public var carbonModifiers: UInt32 + + // Human-readable strings + var displayString: String { + var parts: [String] = [] + if carbonModifiers & UInt32(cmdKey) != 0 { parts.append("⌘ Command") } + if carbonModifiers & UInt32(optionKey) != 0 { parts.append("⌥ Option") } + if carbonModifiers & UInt32(controlKey) != 0 { parts.append("⌃ Control") } + if carbonModifiers & UInt32(shiftKey) != 0 { parts.append("⇧ Shift") } + parts.append(Self.keyName(for: keyCode)) + return parts.joined() + } + + private static func keyName(for keyCode: UInt32) -> String { + let source = CGEventSource(stateID: .hidSystemState) + if let cgEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(keyCode), keyDown: true), + let nsEvent = NSEvent(cgEvent: cgEvent) { + let chars = nsEvent.charactersIgnoringModifiers?.uppercased() ?? "" + if !chars.isEmpty && chars.rangeOfCharacter(from: .controlCharacters) == nil { + return chars + } + } + + // Fallback for non-pritable keys + switch Int(keyCode) { + case kVK_ANSI_A: return "A" + case kVK_ANSI_B: return "B" + case kVK_ANSI_C: return "C" + case kVK_ANSI_D: return "D" + case kVK_ANSI_E: return "E" + case kVK_ANSI_F: return "F" + case kVK_ANSI_G: return "G" + case kVK_ANSI_H: return "H" + case kVK_ANSI_I: return "I" + case kVK_ANSI_J: return "J" + case kVK_ANSI_K: return "K" + case kVK_ANSI_L: return "L" + case kVK_ANSI_M: return "M" + case kVK_ANSI_N: return "N" + case kVK_ANSI_O: return "O" + case kVK_ANSI_P: return "P" + case kVK_ANSI_Q: return "Q" + case kVK_ANSI_R: return "R" + case kVK_ANSI_S: return "S" + case kVK_ANSI_T: return "T" + case kVK_ANSI_U: return "U" + case kVK_ANSI_V: return "V" + case kVK_ANSI_W: return "W" + case kVK_ANSI_X: return "X" + case kVK_ANSI_Y: return "Y" + case kVK_ANSI_Z: return "Z" + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_F1: return "F1" + case kVK_F2: return "F2" + case kVK_F3: return "F3" + case kVK_F4: return "F4" + case kVK_F5: return "F5" + case kVK_F6: return "F6" + case kVK_F7: return "F7" + case kVK_F8: return "F8" + case kVK_F9: return "F9" + case kVK_F10: return "F10" + case kVK_F11: return "F11" + case kVK_F12: return "F12" + case kVK_Space: return "Space" + case kVK_Tab: return "Tab" + case kVK_Return: return "Return" + case kVK_Delete: return "Delete" + case kVK_Escape: return "Esc" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_UpArrow: return "↑" + case kVK_DownArrow: return "↓" + default: return unsafe String(format: "0x%02X", keyCode) + } + } +} + // MARK: - User Preferences /// User preferences that persist across app launches @@ -150,6 +244,16 @@ public struct UserPreferences: Codable, Sendable { // Title bar passthrough - pass gesture to system when cursor is over window title bar var passThroughTitleBar: Bool = false var titleBarHeight: Double = 28 // Height of title bar region in pixels + + // Hotkey bindings + public var toggleHotKey: HotKeyBinding = HotKeyBinding( + keyCode: UInt32(kVK_ANSI_E), + carbonModifiers: UInt32(cmdKey) | UInt32(optionKey) + ) + public var menuBarHotKey: HotKeyBinding = HotKeyBinding( + keyCode: UInt32(kVK_ANSI_M), + carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey) + ) /// Convert to GestureConfiguration public var gestureConfig: GestureConfiguration { diff --git a/MiddleDrag/UI/HotKeyRecorderView.swift b/MiddleDrag/UI/HotKeyRecorderView.swift new file mode 100644 index 0000000..ccd59d9 --- /dev/null +++ b/MiddleDrag/UI/HotKeyRecorderView.swift @@ -0,0 +1,107 @@ +// +// HotKeyRecorderView.swift +// MiddleDrag +// + +import Cocoa +import Carbon.HIToolbox + +/// A button that captures the next key + modifier combo when clicked. +/// Displays the current binding as a human-readable string (e.g. "⌘⇧E"). +@MainActor +final class HotKeyRecorderView: NSButton { + + var binding: HotKeyBinding { + didSet { updateLabel() } + } + + var onBindingChanged: ((HotKeyBinding) -> Void)? + + private var isRecording = false + private var localMonitor: Any? + + init(binding: HotKeyBinding) { + self.binding = binding + super.init(frame: .zero) + bezelStyle = .recessed + setButtonType(.momentaryPushIn) + isBordered = true + font = .monospacedSystemFont(ofSize: 12, weight: .medium) + updateLabel() + target = self + action = #selector(startRecording) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + func invalidate() { + // Safety net: ensure the local monitor is removed even if stopRecording + // was never called (e.g. alert dismissed without resignFirstResponder) + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + + private func updateLabel() { + title = isRecording ? "Press a key…" : binding.displayString + } + + @objc private func startRecording() { + guard !isRecording else { return } + isRecording = true + updateLabel() + + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handleKeyDown(event) + return nil // swallow the event + } + } + + /// Cancel any in-progress recording and remove the keyboard monitor. + /// Call this when the hosting UI is being dismissed to prevent leaked monitors. + func cancelRecording() { + if isRecording { stopRecording() } + } + + private func stopRecording() { + isRecording = false + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + updateLabel() + } + + private func handleKeyDown(_ event: NSEvent) { + let modifiers = GlobalHotKeyManager.carbonModifiers(from: event.modifierFlags) + + // Escape cancels without changing the binding + if event.keyCode == UInt16(kVK_Escape) { + stopRecording() + return + } + + // Require at least one modifier (bare keys are too easy to trigger accidentally) + guard modifiers != 0 else { return } + + let newBinding = HotKeyBinding( + keyCode: UInt32(event.keyCode), + carbonModifiers: modifiers + ) + + binding = newBinding + stopRecording() + onBindingChanged?(newBinding) + } + + // If the view loses focus while recording, cancel + override func resignFirstResponder() -> Bool { + if isRecording { stopRecording() } + return super.resignFirstResponder() + } +} + diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 189dea5..16c9e00 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -1,4 +1,5 @@ import Cocoa +import Carbon.HIToolbox /// Manages the menu bar UI and user interactions @MainActor @@ -17,6 +18,7 @@ public class MenuBarController: NSObject { } private weak var multitouchManager: MultitouchManager? private var preferences: UserPreferences + private(set) var isMenuBarVisible = true // Menu item tags for easy reference private enum MenuItemTag: Int { @@ -129,6 +131,7 @@ public class MenuBarController: NSObject { // Actions menu.addItem(createMenuItem(title: "Quick Setup", action: #selector(showQuickSetup))) + menu.addItem(createMenuItem(title: "Hide Menu Bar Icon (⌘⇧M or Spotlight to restore)", action: #selector(hideMenuBarIcon))) menu.addItem(createMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) statusItem.menu = menu @@ -327,6 +330,25 @@ public class MenuBarController: NSObject { ) forceReleaseItem.target = self submenu.addItem(forceReleaseItem) + + submenu.addItem(NSMenuItem.separator()) + + // Hotkey rebinding + let hotkeyItem = NSMenuItem( + title: "Change Toggle Hotkey (\(preferences.toggleHotKey.displayString))…", + action: #selector(rebindToggleHotKey), + keyEquivalent: "" + ) + hotkeyItem.target = self + submenu.addItem(hotkeyItem) + + let menuBarHotkeyItem = NSMenuItem( + title: "Change Menu Bar Hotkey (\(preferences.menuBarHotKey.displayString))…", + action: #selector(rebindMenuBarHotKey), + keyEquivalent: "" + ) + menuBarHotkeyItem.target = self + submenu.addItem(menuBarHotkeyItem) submenu.addItem(NSMenuItem.separator()) @@ -470,7 +492,7 @@ public class MenuBarController: NSObject { buildMenu() } - @objc func toggleEnabled() { + @objc public func toggleEnabled() { multitouchManager?.toggleEnabled() let isEnabled = multitouchManager?.isEnabled ?? false @@ -544,6 +566,60 @@ public class MenuBarController: NSObject { } } } + + @objc func rebindToggleHotKey() { + showHotKeyRecorderPanel( + title: "Toggle MiddleDrag Hotkey", + current: preferences.toggleHotKey + ) { [weak self] newBinding in + guard let self else { return } + self.preferences.toggleHotKey = newBinding + NotificationCenter.default.post(name: .preferencesChanged, object: self.preferences) + self.buildMenu() + } + } + + @objc func rebindMenuBarHotKey() { + showHotKeyRecorderPanel( + title: "Menu Bar Visibility Hotkey", + current: preferences.menuBarHotKey + ) { [weak self] newBinding in + guard let self else { return } + self.preferences.menuBarHotKey = newBinding + NotificationCenter.default.post(name: .preferencesChanged, object: self.preferences) + self.buildMenu() + } + } + + private func showHotKeyRecorderPanel( + title: String, + current: HotKeyBinding, + onAccept: @escaping (HotKeyBinding) -> Void + ) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = "Press a new key combination (with at least one modifier). Press Escape to cancel." + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + let recorder = HotKeyRecorderView(binding: current) + recorder.frame = NSRect(x: 0, y: 0, width: 200, height: 24) + alert.accessoryView = recorder + + // Bring app to front so the alert can receive key events + NSApp.activate(ignoringOtherApps: true) + + let response = alert.runModal() + + // Ensure the recorder's local keyboard monitor is cleaned up regardless + // of how the alert was dismissed (clicking OK/Cancel without pressing a key + // does not trigger resignFirstResponder on the accessory view) + recorder.cancelRecording() + + if response == .alertFirstButtonReturn { + onAccept(recorder.binding) + } + } // MARK: - Palm Rejection Actions @@ -758,6 +834,42 @@ public class MenuBarController: NSObject { @objc private func quit() { NSApplication.shared.terminate(nil) } + + // MARK: - Menu Bar Visibility + + @objc func hideMenuBarIcon() { + setMenuBarVisible(false) + } + + /// Show the menu bar icon (no-op if already visible). Called from Spotlight reopen. + public func showMenuBarIcon() { + guard !isMenuBarVisible else { return } + setMenuBarVisible(true) + } + + /// Toggle menu bar icon visibility. Called from the global hotkey (⌘⇧M). + public func toggleMenuBarVisibility() { + setMenuBarVisible(!isMenuBarVisible) + } + + private func setMenuBarVisible(_ visible: Bool) { + isMenuBarVisible = visible + statusItem.isVisible = visible + + if visible { + // Rebuild menu and update icon to reflect current state + let isEnabled = multitouchManager?.isEnabled ?? false + updateStatusIcon(enabled: isEnabled) + buildMenu() + + // Pop the menu open so the user knows it's back + // Skip during tests — performClick opens a modal menu loop that stalls CI + let isRunningTests = NSClassFromString("XCTestCase") != nil + if !isRunningTests, let button = statusItem.button { + button.performClick(nil) + } + } + } } // MARK: - Notification Names diff --git a/MiddleDrag/Utilities/GlobalHotKeyManager.swift b/MiddleDrag/Utilities/GlobalHotKeyManager.swift new file mode 100644 index 0000000..fddf472 --- /dev/null +++ b/MiddleDrag/Utilities/GlobalHotKeyManager.swift @@ -0,0 +1,130 @@ +// +// GlobalHotKeyManager.swift +// MiddleDrag +// + +import Cocoa +import Carbon.HIToolbox + +/// Manages system-wide hotkeys using Carbon's RegisterEventHotKey +/// Threading: Delivers handlers on the main thread +@safe @MainActor +public final class GlobalHotKeyManager { + public static let shared = GlobalHotKeyManager() + + // Map hotkey IDs to handlers + private var handlers: [UInt32: () -> Void] = [:] + private var hotKeyRefs: [UInt32: EventHotKeyRef?] = unsafe [:] + private var nextID: UInt32 = 1 + + // Keep a reference to the installed event handler + private var eventHandler: EventHandlerRef? + + // Unique signature to identify our hotkeys (any 4-byte code) + private let signature: OSType = 0x4D44484B // 'MDHK' + + private init() { + // Install a single event handler for all hotkeys we register + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + + let callback: EventHandlerUPP = { (_, eventRef, userData) in + // Extract the EventHotKeyID for the pressed hotkey + var hotKeyID = EventHotKeyID() + let status = unsafe GetEventParameter(eventRef, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size(ofValue: hotKeyID), + nil, + &hotKeyID) + guard status == noErr else { return noErr } + + // Bridge back to Swift instance + if let userData = unsafe userData { + let manager = unsafe Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + let id = hotKeyID.id + if let handler = manager.handlers[id] { + // Deliver on main thread to safely call AppKit/UI code + DispatchQueue.main.async { + handler() + } + } + } + return noErr + } + + unsafe InstallEventHandler(GetEventDispatcherTarget(), + callback, + 1, + &eventType, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + &eventHandler) + } + + func invalidate() { + // Unregister all hotkeys and remove the handler + for unsafe (_, ref) in unsafe hotKeyRefs { + if let ref = unsafe ref { unsafe UnregisterEventHotKey(ref) } + } + unsafe hotKeyRefs.removeAll() + + if let handler = unsafe eventHandler { + unsafe RemoveEventHandler(handler) + unsafe eventHandler = nil + } + } + + /// Register a global hotkey + /// - Parameters: + /// - keyCode: A virtual key code (e.g. kVK_ANSI_E) + /// - modifiers: Carbon modifier mask (e.g. cmdKey | shiftKey) + /// - handler: Closure invoked when the hotkey is pressed + /// - Returns: An identifier to later unregister if needed + @discardableResult + public func register(keyCode: UInt32, modifiers: UInt32, handler: @escaping () -> Void) -> UInt32 { + let id = nextID + nextID &+= 1 + + var ref: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: signature, id: id) + + let status = unsafe RegisterEventHotKey(keyCode, + modifiers, + hotKeyID, + GetEventDispatcherTarget(), + 0, + &ref) + + guard status == noErr, let _ = unsafe ref else { + // Registration can fail if another app already claimed the combo + NSLog("GlobalHotKeyManager: Failed to register hotkey (code \(keyCode), mods \(modifiers))") + return 0 + } + + unsafe hotKeyRefs[id] = unsafe ref + handlers[id] = handler + return id + } + + /// Unregister a previously registered hotkey by ID + public func unregister(id: UInt32) { + if let ref = unsafe hotKeyRefs[id] { + if let ref = unsafe ref { unsafe UnregisterEventHotKey(ref) } + unsafe hotKeyRefs[id] = nil + } + handlers[id] = nil + } + + /// Utility: Convert NSEvent.ModifierFlags to Carbon modifiers + public static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { + var result: UInt32 = 0 + if flags.contains(.command) { result |= UInt32(cmdKey) } + if flags.contains(.option) { result |= UInt32(optionKey) } + if flags.contains(.shift) { result |= UInt32(shiftKey) } + if flags.contains(.control) { result |= UInt32(controlKey) } + return result + } +} diff --git a/MiddleDrag/Utilities/PreferencesManager.swift b/MiddleDrag/Utilities/PreferencesManager.swift index c1e5d0f..b8defad 100644 --- a/MiddleDrag/Utilities/PreferencesManager.swift +++ b/MiddleDrag/Utilities/PreferencesManager.swift @@ -1,4 +1,5 @@ import Foundation +import Carbon /// Manages user preferences persistence /// Thread-safe: UserDefaults is internally synchronized @@ -37,6 +38,11 @@ public final class PreferencesManager: @unchecked Sendable { static let allowReliftDuringDrag = "allowReliftDuringDrag" // Gesture configuration prompt tracking static let hasShownGestureConfigurationPrompt = "hasShownGestureConfigurationPrompt" + // Hotkey binding keys + static let toggleHotKeyCode = "toggleHotKeyCode" + static let toggleHotKeyModifiers = "toggleHotKeyModifiers" + static let menuBarHotKeyCode = "menuBarHotKeyCode" + static let menuBarHotKeyModifiers = "menuBarHotKeyModifiers" } /// Production initializer using UserDefaults.standard @@ -81,6 +87,12 @@ public final class PreferencesManager: @unchecked Sendable { Keys.allowReliftDuringDrag: false, // Gesture configuration prompt tracking Keys.hasShownGestureConfigurationPrompt: false, + // Hotkey defaults + Keys.toggleHotKeyCode: UInt32(kVK_ANSI_E), + Keys.toggleHotKeyModifiers: UInt32(cmdKey) | UInt32(shiftKey), + Keys.menuBarHotKeyCode: UInt32(kVK_ANSI_M), + Keys.menuBarHotKeyModifiers: UInt32(cmdKey) | UInt32(shiftKey), + ]) } @@ -115,6 +127,14 @@ public final class PreferencesManager: @unchecked Sendable { prefs.passThroughTitleBar = userDefaults.bool(forKey: Keys.passThroughTitleBar) prefs.titleBarHeight = userDefaults.double(forKey: Keys.titleBarHeight) prefs.allowReliftDuringDrag = userDefaults.bool(forKey: Keys.allowReliftDuringDrag) + prefs.toggleHotKey = HotKeyBinding( + keyCode: UInt32(userDefaults.integer(forKey: Keys.toggleHotKeyCode)), + carbonModifiers: UInt32(userDefaults.integer(forKey: Keys.toggleHotKeyModifiers)) + ) + prefs.menuBarHotKey = HotKeyBinding( + keyCode: UInt32(userDefaults.integer(forKey: Keys.menuBarHotKeyCode)), + carbonModifiers: UInt32(userDefaults.integer(forKey: Keys.menuBarHotKeyModifiers)) + ) return prefs } @@ -148,6 +168,11 @@ public final class PreferencesManager: @unchecked Sendable { userDefaults.set(preferences.titleBarHeight, forKey: Keys.titleBarHeight) // Relift during drag userDefaults.set(preferences.allowReliftDuringDrag, forKey: Keys.allowReliftDuringDrag) + // Hotkey bindings + userDefaults.set(Int(preferences.toggleHotKey.keyCode), forKey: Keys.toggleHotKeyCode) + userDefaults.set(Int(preferences.toggleHotKey.carbonModifiers), forKey: Keys.toggleHotKeyModifiers) + userDefaults.set(Int(preferences.menuBarHotKey.keyCode), forKey: Keys.menuBarHotKeyCode) + userDefaults.set(Int(preferences.menuBarHotKey.carbonModifiers), forKey: Keys.menuBarHotKeyModifiers) } // MARK: - Gesture Configuration Prompt Tracking