Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion MiddleDrag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -109,7 +113,6 @@
membershipExceptions = (
Debug.xcconfig,
Release.xcconfig,
Secrets.xcconfig,
);
target = 1A0000011 /* MiddleDrag */;
};
Expand All @@ -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,
Expand All @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions MiddleDrag/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Cocoa
import Carbon.HIToolbox
import MiddleDragCore

/// Main application delegate
Expand All @@ -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

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -160,6 +167,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return true
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
menuBarController?.toggleMenuBarVisibility()
return false
}

// MARK: - Setup

private func setupNotifications() {
Expand All @@ -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

Expand All @@ -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)
}
}
Expand Down
4 changes: 2 additions & 2 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions MiddleDrag/MiddleDragTests/AlertHelperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion MiddleDrag/MiddleDragTests/GestureRecognizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<MTTouch>.allocate(capacity: count)
Expand Down
136 changes: 136 additions & 0 deletions MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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 testCarbonModifiersCombined() {
let result = GlobalHotKeyManager.carbonModifiers(from: [.command, .shift])
XCTAssertEqual(result, UInt32(cmdKey) | UInt32(shiftKey))
}

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 testCarbonModifiersIgnoresNonModifierFlags() {
// .capsLock is not mapped by our utility - should not appear in result
let result = GlobalHotKeyManager.carbonModifiers(from: .capsLock)
XCTAssertEqual(result, 0)
}

// MARK: - Singleton Tests

func testSharedInstanceIsSingleton() {
let a = GlobalHotKeyManager.shared
let b = GlobalHotKeyManager.shared
XCTAssertTrue(a === b)
}

// MARK: - Register / Unregister Tests

func testRegisterReturnsNonZeroID() {
// 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 testUnregisterInvalidIDDoesNotCrash() {
// Unregistering an ID that was never registered should be safe
XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: 99999))
}

func testUnregisterZeroIDDoesNotCrash() {
// 0 is the failure return value from register - unregistering it should be safe
XCTAssertNoThrow(GlobalHotKeyManager.shared.unregister(id: 0))
}

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))
}
}

// MARK: - Handler Invocation Tests

func testRegisterStoresHandler() {
var handlerCalled = false
let id = GlobalHotKeyManager.shared.register(
keyCode: UInt32(kVK_ANSI_G),
modifiers: UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey)
) {
handlerCalled = true
}

// We can't easily simulate a Carbon hotkey press in tests,
// but we verify the handler was stored (not called yet)
XCTAssertFalse(handlerCalled)

if id != 0 { GlobalHotKeyManager.shared.unregister(id: id) }
}
}
Loading
Loading