Skip to content

Commit c3bbf22

Browse files
Add global hotkeys for toggling MiddleDrag and menu bar visibility (#121)
* Add global hotkeys for toggling MiddleDrag and menu bar visibility - Implemented global hotkey ⌘⇧E to toggle MiddleDrag functionality. - Implemented global hotkey ⌘⇧M to toggle the visibility of the menu bar icon. - Updated MenuBarController to handle menu bar icon visibility and added corresponding menu item. This enhances user accessibility and control over the application features. * Implement hotkey customization and recording functionality - Added a new HotKeyRecorderView for capturing user-defined hotkey combinations. - Updated UserPreferences to include customizable hotkey bindings for toggling MiddleDrag and menu bar visibility. - Enhanced MenuBarController to allow users to change hotkeys via the menu. - Refactored hotkey registration logic in AppDelegate to support dynamic updates based on user preferences. This improves user experience by providing flexibility in hotkey configuration. * Add applicationShouldHandleReopen method and update menu item text - Implemented applicationShouldHandleReopen in AppDelegate to toggle menu bar visibility when the application is reopened. - Updated the menu item text in MenuBarController to clarify the method for restoring the menu bar icon. These changes enhance user interaction with the application and improve clarity in the menu options. * Implement cleanup for hotkey recording and improve alert handling - Added a deinitializer in HotKeyRecorderView to ensure the local keyboard monitor is removed when the instance is deallocated. - Introduced a cancelRecording method to stop any ongoing recording and clean up the keyboard monitor. - Updated MenuBarController to call cancelRecording when an alert is dismissed, ensuring no leaked monitors occur. These changes enhance memory safety and improve the user experience by preventing potential issues with lingering keyboard monitors. * Refactor multitouch and hotkey handling for improved safety and functionality - Updated MultitouchManager and related tests to ensure safe memory handling with the introduction of 'unsafe' keyword in critical areas. - Enhanced AlertHelperTests and SystemGestureHelperTests to utilize 'unsafe' settings provider for better test isolation. - Added new tests for GlobalHotKeyManager and HotKeyRecorderView to validate hotkey registration and recording functionality. - Improved MenuBarController tests to ensure menu bar visibility toggling works as expected. These changes enhance the robustness of multitouch and hotkey functionalities while ensuring better test coverage. * Refactor menu bar visibility handling and enhance hotkey tests - Updated AppDelegate to call showMenuBarIcon instead of toggleMenuBarVisibility for improved clarity. - Added new tests for MenuBarController to verify the behavior of showMenuBarIcon under various conditions. - Enhanced GlobalHotKeyManagerTests and HotKeyRecorderViewTests with additional test cases for better coverage and validation of hotkey functionality. These changes improve the robustness of menu bar interactions and ensure comprehensive testing of hotkey features. * Enhance MenuBarController to skip button click during tests - Added a check to prevent the button click action in MenuBarController when running tests, avoiding modal menu loops that can stall CI processes. This change improves test reliability and ensures smoother continuous integration workflows. * Refactor MenuBarController hotkey handling for improved safety - Updated hotkey binding closures in MenuBarController to use guard statements for safer self-referencing. - This change enhances memory safety by preventing potential retain cycles and ensures smoother execution of hotkey updates. These improvements contribute to the overall robustness of the menu bar functionality.
1 parent 3834973 commit c3bbf22

17 files changed

Lines changed: 1379 additions & 25 deletions

MiddleDrag.xcodeproj/project.pbxproj

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@
7070
Models/GestureModels.swift,
7171
Models/TouchModels.swift,
7272
UI/AlertHelper.swift,
73+
UI/HotKeyRecorderView.swift,
7374
UI/MenuBarController.swift,
7475
Utilities/AnalyticsManager.swift,
76+
Utilities/GlobalHotKeyManager.swift,
7577
Utilities/LaunchAtLoginManager.swift,
7678
Utilities/PreferencesManager.swift,
7779
Utilities/ScreenHelper.swift,
@@ -90,6 +92,8 @@
9092
MiddleDragTests/DeviceMonitorTests.swift,
9193
MiddleDragTests/GestureModelsTests.swift,
9294
MiddleDragTests/GestureRecognizerTests.swift,
95+
MiddleDragTests/GlobalHotKeyManagerTests.swift,
96+
MiddleDragTests/HotKeyRecorderViewTests.swift,
9397
MiddleDragTests/LaunchAtLoginManagerTests.swift,
9498
MiddleDragTests/MenuBarControllerTests.swift,
9599
MiddleDragTests/Mocks/MockDeviceMonitor.swift,
@@ -109,7 +113,6 @@
109113
membershipExceptions = (
110114
Debug.xcconfig,
111115
Release.xcconfig,
112-
Secrets.xcconfig,
113116
);
114117
target = 1A0000011 /* MiddleDrag */;
115118
};
@@ -131,6 +134,8 @@
131134
MiddleDragTests/DeviceMonitorTests.swift,
132135
MiddleDragTests/GestureModelsTests.swift,
133136
MiddleDragTests/GestureRecognizerTests.swift,
137+
MiddleDragTests/GlobalHotKeyManagerTests.swift,
138+
MiddleDragTests/HotKeyRecorderViewTests.swift,
134139
MiddleDragTests/LaunchAtLoginManagerTests.swift,
135140
MiddleDragTests/MenuBarControllerTests.swift,
136141
MiddleDragTests/Mocks/MockDeviceMonitor.swift,
@@ -145,8 +150,10 @@
145150
Models/GestureModels.swift,
146151
Models/TouchModels.swift,
147152
UI/AlertHelper.swift,
153+
UI/HotKeyRecorderView.swift,
148154
UI/MenuBarController.swift,
149155
Utilities/AnalyticsManager.swift,
156+
Utilities/GlobalHotKeyManager.swift,
150157
Utilities/LaunchAtLoginManager.swift,
151158
Utilities/PreferencesManager.swift,
152159
Utilities/ScreenHelper.swift,

MiddleDrag/AppDelegate.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Cocoa
2+
import Carbon.HIToolbox
23
import MiddleDragCore
34

45
/// Main application delegate
@@ -18,6 +19,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1819
private var preferences: UserPreferences!
1920

2021
private var accessibilityMonitor: AccessibilityMonitor?
22+
23+
private var toggleHotKeyID: UInt32 = 0
24+
private var menuBarHotKeyID: UInt32 = 0
2125

2226
// MARK: - Application Lifecycle
2327

@@ -111,6 +115,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
111115
)
112116
Log.info("Menu bar controller initialized", category: .app)
113117

118+
// Register global hotkeys
119+
registerHotKeys()
120+
114121
// Set up notification observers
115122
setupNotifications()
116123

@@ -160,6 +167,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
160167
return true
161168
}
162169

170+
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
171+
menuBarController?.showMenuBarIcon()
172+
return false
173+
}
174+
163175
// MARK: - Setup
164176

165177
private func setupNotifications() {
@@ -177,6 +189,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
177189
object: nil
178190
)
179191
}
192+
193+
/// Register (or re-register) global hotkeys from current preferences
194+
private func registerHotKeys() {
195+
// Unregister existing
196+
if toggleHotKeyID != 0 {
197+
GlobalHotKeyManager.shared.unregister(id: toggleHotKeyID)
198+
}
199+
if menuBarHotKeyID != 0 {
200+
GlobalHotKeyManager.shared.unregister(id: menuBarHotKeyID)
201+
}
202+
203+
// Register from preferences
204+
toggleHotKeyID = GlobalHotKeyManager.shared.register(
205+
keyCode: preferences.toggleHotKey.keyCode,
206+
modifiers: preferences.toggleHotKey.carbonModifiers
207+
) { [weak self] in
208+
self?.menuBarController?.toggleEnabled()
209+
}
210+
211+
menuBarHotKeyID = GlobalHotKeyManager.shared.register(
212+
keyCode: preferences.menuBarHotKey.keyCode,
213+
modifiers: preferences.menuBarHotKey.carbonModifiers
214+
) { [weak self] in
215+
self?.menuBarController?.toggleMenuBarVisibility()
216+
}
217+
}
180218

181219
// MARK: - Notification Handlers
182220

@@ -185,6 +223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
185223
preferences = newPreferences
186224
PreferencesManager.shared.savePreferences(preferences)
187225
multitouchManager.updateConfiguration(preferences.gestureConfig)
226+
registerHotKeys()
188227
Log.info("Preferences updated", category: .app)
189228
}
190229
}

MiddleDrag/Managers/MultitouchManager.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,9 +870,9 @@ extension MultitouchManager: DeviceMonitorDelegate {
870870

871871
gestureQueue.async { [weak self] in
872872
if let data = touchData {
873-
data.withUnsafeBytes { rawBuffer in
873+
unsafe data.withUnsafeBytes { rawBuffer in
874874
guard let baseAddress = rawBuffer.baseAddress else { return }
875-
let buffer = UnsafeMutableRawPointer(mutating: baseAddress)
875+
let buffer = unsafe UnsafeMutableRawPointer(mutating: baseAddress)
876876
unsafe self?.gestureRecognizer.processTouches(
877877
buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags)
878878
}

MiddleDrag/MiddleDragTests/AlertHelperTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@ final class AlertHelperTests: XCTestCase {
4646
AlertHelper.presenter = mockPresenter
4747

4848
// Save and replace SystemGestureHelper settings provider
49-
originalSettingsProvider = SystemGestureHelper.settingsProvider
49+
originalSettingsProvider = unsafe SystemGestureHelper.settingsProvider
5050
mockSettingsProvider = MockTrackpadSettingsProvider()
51-
SystemGestureHelper.settingsProvider = mockSettingsProvider
51+
unsafe SystemGestureHelper.settingsProvider = mockSettingsProvider
5252
}
5353

5454
override func tearDown() {
5555
// Restore originals
5656
AlertHelper.presenter = originalPresenter
57-
SystemGestureHelper.settingsProvider = originalSettingsProvider
57+
unsafe SystemGestureHelper.settingsProvider = originalSettingsProvider
5858
mockPresenter = nil
5959
mockSettingsProvider = nil
6060
super.tearDown()

MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ import XCTest
8080
}
8181

8282
func testStartStopStartDoesNotCrash() throws {
83-
try requireUnsafeMultitouchTestsEnabled()
83+
try unsafe requireUnsafeMultitouchTestsEnabled()
8484
// Should be able to restart the monitor
8585
unsafe XCTAssertNoThrow(monitor.start())
8686
unsafe XCTAssertNoThrow(monitor.stop())
@@ -122,7 +122,7 @@ import XCTest
122122
// MARK: - Multiple Instance Tests
123123

124124
func testMultipleInstancesDoNotCrash() throws {
125-
try requireUnsafeMultitouchTestsEnabled()
125+
try unsafe requireUnsafeMultitouchTestsEnabled()
126126
// Create multiple monitors - only first should own global reference
127127
let monitor2 = unsafe DeviceMonitor()
128128
let monitor3 = unsafe DeviceMonitor()
@@ -208,7 +208,7 @@ import XCTest
208208
}
209209

210210
func testRapidStartStopCyclesDoNotCrash() throws {
211-
try requireUnsafeMultitouchTestsEnabled()
211+
try unsafe requireUnsafeMultitouchTestsEnabled()
212212
// Simulates the race condition scenario where rapid restart cycles
213213
// could cause the framework's internal thread to access deallocated resources.
214214
// The fix adds delays to prevent this, so rapid cycles should be safe.
@@ -222,7 +222,7 @@ import XCTest
222222
}
223223

224224
func testStopSeparatesCallbackUnregistrationFromDeviceStop() throws {
225-
try requireUnsafeMultitouchTestsEnabled()
225+
try unsafe requireUnsafeMultitouchTestsEnabled()
226226
// This test exercises the code path where:
227227
// 1. Callbacks are unregistered first (MTUnregisterContactFrameCallback)
228228
// 2. A delay occurs (Thread.sleep)
@@ -241,7 +241,7 @@ import XCTest
241241
}
242242

243243
func testConcurrentStopDoesNotCrash() throws {
244-
try requireUnsafeMultitouchTestsEnabled()
244+
try unsafe requireUnsafeMultitouchTestsEnabled()
245245
// Test that even if something tries to access the monitor during stop,
246246
// it doesn't crash. This simulates what happens when the framework's
247247
// internal thread is still processing while we stop.
@@ -287,7 +287,7 @@ import XCTest
287287
}
288288

289289
func testRapidRestartCyclesWithDelayDoNotCrash() throws {
290-
try requireUnsafeMultitouchTestsEnabled()
290+
try unsafe requireUnsafeMultitouchTestsEnabled()
291291
// Simulates the exact scenario from the bug report:
292292
// Rapid restart cycles during connectivity changes causing
293293
// gDeviceMonitor to become nil while callbacks are still in-flight.
@@ -318,7 +318,7 @@ import XCTest
318318
}
319319

320320
func testConcurrentStartStopDoesNotCrash() throws {
321-
try requireUnsafeMultitouchTestsEnabled()
321+
try unsafe requireUnsafeMultitouchTestsEnabled()
322322
// Test that concurrent start/stop operations on the same instance don't crash.
323323
// NOTE: Concurrent start/stop on the same instance may leave it in an inconsistent
324324
// state, but it should NOT crash due to the locking mechanism protecting global state.
@@ -353,7 +353,7 @@ import XCTest
353353
}
354354

355355
func testMultipleMonitorCreationDuringCleanup() throws {
356-
try requireUnsafeMultitouchTestsEnabled()
356+
try unsafe requireUnsafeMultitouchTestsEnabled()
357357
// Test that creating new monitors while the old one is being cleaned up
358358
// doesn't cause a crash. This tests the gPendingCleanup mechanism.
359359
unsafe monitor.start()

MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ final class GestureRecognizerTests: XCTestCase {
5757
let count = touches.count
5858
guard count > 0 else {
5959
// Non-null placeholder; processTouches won't dereference when count == 0.
60-
return (UnsafeMutableRawPointer(bitPattern: 1)!, 0, {})
60+
return unsafe (UnsafeMutableRawPointer(bitPattern: 1)!, 0, {})
6161
}
6262

6363
let pointer = UnsafeMutablePointer<MTTouch>.allocate(capacity: count)

0 commit comments

Comments
 (0)