From a0618aabd6b537c9c77d7e652d9b8a42b617ec04 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:19:28 -0700 Subject: [PATCH 1/8] Add global hotkeys for toggling MiddleDrag and menu bar visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- MiddleDrag.xcodeproj/project.pbxproj | 3 +- MiddleDrag/AppDelegate.swift | 17 +++ MiddleDrag/UI/MenuBarController.swift | 33 ++++- .../Utilities/GlobalHotKeyManager.swift | 130 ++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 MiddleDrag/Utilities/GlobalHotKeyManager.swift diff --git a/MiddleDrag.xcodeproj/project.pbxproj b/MiddleDrag.xcodeproj/project.pbxproj index 8e9a095..7bb81ab 100644 --- a/MiddleDrag.xcodeproj/project.pbxproj +++ b/MiddleDrag.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ UI/AlertHelper.swift, UI/MenuBarController.swift, Utilities/AnalyticsManager.swift, + Utilities/GlobalHotKeyManager.swift, Utilities/LaunchAtLoginManager.swift, Utilities/PreferencesManager.swift, Utilities/ScreenHelper.swift, @@ -109,7 +110,6 @@ membershipExceptions = ( Debug.xcconfig, Release.xcconfig, - Secrets.xcconfig, ); target = 1A0000011 /* MiddleDrag */; }; @@ -147,6 +147,7 @@ UI/AlertHelper.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..48638d9 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -1,4 +1,5 @@ import Cocoa +import Carbon.HIToolbox import MiddleDragCore /// Main application delegate @@ -111,6 +112,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) Log.info("Menu bar controller initialized", category: .app) + // Register global hotkey ⌘⇧E to toggle MiddleDrag + GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_E), + modifiers: GlobalHotKeyManager.carbonModifiers(from: [.command, .shift]) + ) { [weak self] in + self?.menuBarController?.toggleEnabled() + } + + // Register global hotkey ⌘⇧M to toggle menu bar icon visibility + GlobalHotKeyManager.shared.register( + keyCode: UInt32(kVK_ANSI_M), + modifiers: GlobalHotKeyManager.carbonModifiers(from: [.command, .shift]) + ) { [weak self] in + self?.menuBarController?.toggleMenuBarVisibility() + } + // Set up notification observers setupNotifications() diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 189dea5..5a2ccf1 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 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 to restore)", action: #selector(hideMenuBarIcon))) menu.addItem(createMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) statusItem.menu = menu @@ -470,7 +473,7 @@ public class MenuBarController: NSObject { buildMenu() } - @objc func toggleEnabled() { + @objc public func toggleEnabled() { multitouchManager?.toggleEnabled() let isEnabled = multitouchManager?.isEnabled ?? false @@ -758,6 +761,34 @@ public class MenuBarController: NSObject { @objc private func quit() { NSApplication.shared.terminate(nil) } + + // MARK: - Menu Bar Visibility + + @objc func hideMenuBarIcon() { + setMenuBarVisible(false) + } + + /// 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 + if 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..0eed422 --- /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 + 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 + } +} From e211237eaf7a1c171fa9bd5b5c68f4cfe63378d5 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:04:56 -0700 Subject: [PATCH 2/8] 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. --- MiddleDrag.xcodeproj/project.pbxproj | 2 + MiddleDrag/AppDelegate.swift | 47 +++++--- MiddleDrag/Models/GestureModels.swift | 104 ++++++++++++++++++ MiddleDrag/UI/HotKeyRecorderView.swift | 92 ++++++++++++++++ MiddleDrag/UI/MenuBarController.swift | 65 +++++++++++ .../Utilities/GlobalHotKeyManager.swift | 2 +- MiddleDrag/Utilities/PreferencesManager.swift | 25 +++++ 7 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 MiddleDrag/UI/HotKeyRecorderView.swift diff --git a/MiddleDrag.xcodeproj/project.pbxproj b/MiddleDrag.xcodeproj/project.pbxproj index 7bb81ab..5d7ffbc 100644 --- a/MiddleDrag.xcodeproj/project.pbxproj +++ b/MiddleDrag.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ Models/GestureModels.swift, Models/TouchModels.swift, UI/AlertHelper.swift, + UI/HotKeyRecorderView.swift, UI/MenuBarController.swift, Utilities/AnalyticsManager.swift, Utilities/GlobalHotKeyManager.swift, @@ -145,6 +146,7 @@ Models/GestureModels.swift, Models/TouchModels.swift, UI/AlertHelper.swift, + UI/HotKeyRecorderView.swift, UI/MenuBarController.swift, Utilities/AnalyticsManager.swift, Utilities/GlobalHotKeyManager.swift, diff --git a/MiddleDrag/AppDelegate.swift b/MiddleDrag/AppDelegate.swift index 48638d9..22785b7 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -19,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 @@ -112,21 +115,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) Log.info("Menu bar controller initialized", category: .app) - // Register global hotkey ⌘⇧E to toggle MiddleDrag - GlobalHotKeyManager.shared.register( - keyCode: UInt32(kVK_ANSI_E), - modifiers: GlobalHotKeyManager.carbonModifiers(from: [.command, .shift]) - ) { [weak self] in - self?.menuBarController?.toggleEnabled() - } - - // Register global hotkey ⌘⇧M to toggle menu bar icon visibility - GlobalHotKeyManager.shared.register( - keyCode: UInt32(kVK_ANSI_M), - modifiers: GlobalHotKeyManager.carbonModifiers(from: [.command, .shift]) - ) { [weak self] in - self?.menuBarController?.toggleMenuBarVisibility() - } + // Register global hotkeys + registerHotKeys() // Set up notification observers setupNotifications() @@ -194,6 +184,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 @@ -202,6 +218,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/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..3d1d1ab --- /dev/null +++ b/MiddleDrag/UI/HotKeyRecorderView.swift @@ -0,0 +1,92 @@ +// +// 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") + } + + 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 + } + } + + 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 5a2ccf1..a5a00b0 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -330,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()) @@ -547,6 +566,52 @@ public class MenuBarController: NSObject { } } } + + @objc func rebindToggleHotKey() { + showHotKeyRecorderPanel( + title: "Toggle MiddleDrag Hotkey", + current: preferences.toggleHotKey + ) { [weak self] newBinding in + 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 + 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() + if response == .alertFirstButtonReturn { + onAccept(recorder.binding) + } + } // MARK: - Palm Rejection Actions diff --git a/MiddleDrag/Utilities/GlobalHotKeyManager.swift b/MiddleDrag/Utilities/GlobalHotKeyManager.swift index 0eed422..fddf472 100644 --- a/MiddleDrag/Utilities/GlobalHotKeyManager.swift +++ b/MiddleDrag/Utilities/GlobalHotKeyManager.swift @@ -110,7 +110,7 @@ public final class GlobalHotKeyManager { } /// Unregister a previously registered hotkey by ID - func unregister(id: UInt32) { + public func unregister(id: UInt32) { if let ref = unsafe hotKeyRefs[id] { if let ref = unsafe ref { unsafe UnregisterEventHotKey(ref) } unsafe hotKeyRefs[id] = nil 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 From 5c6fb73ac6546f55a6dd1751d545992c7b6ae60d Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:18:22 -0700 Subject: [PATCH 3/8] 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. --- MiddleDrag/AppDelegate.swift | 5 +++++ MiddleDrag/UI/MenuBarController.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/MiddleDrag/AppDelegate.swift b/MiddleDrag/AppDelegate.swift index 22785b7..3089154 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -167,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() { diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index a5a00b0..75dcd60 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -131,7 +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 to restore)", action: #selector(hideMenuBarIcon))) + 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 From d0b2b66442c824d0efbf906f2ddf683dabe64a02 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:04:58 -0700 Subject: [PATCH 4/8] 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. --- MiddleDrag/UI/HotKeyRecorderView.swift | 14 ++++++++++++++ MiddleDrag/UI/MenuBarController.swift | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/MiddleDrag/UI/HotKeyRecorderView.swift b/MiddleDrag/UI/HotKeyRecorderView.swift index 3d1d1ab..949a78a 100644 --- a/MiddleDrag/UI/HotKeyRecorderView.swift +++ b/MiddleDrag/UI/HotKeyRecorderView.swift @@ -37,6 +37,14 @@ final class HotKeyRecorderView: NSButton { fatalError("init(coder:) not implemented") } + deinit { + // 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) + } + } + private func updateLabel() { title = isRecording ? "Press a key…" : binding.displayString } @@ -52,6 +60,12 @@ final class HotKeyRecorderView: NSButton { } } + /// 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 { diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 75dcd60..fb09d64 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -608,6 +608,12 @@ public class MenuBarController: NSObject { 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) } From faaefbcc0dfa9a461ed26d1ebd56d0f352b11e71 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:19:40 -0700 Subject: [PATCH 5/8] 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. --- MiddleDrag.xcodeproj/project.pbxproj | 4 + MiddleDrag/Managers/MultitouchManager.swift | 4 +- .../MiddleDragTests/AlertHelperTests.swift | 6 +- .../MiddleDragTests/DeviceMonitorTests.swift | 16 +- .../GestureRecognizerTests.swift | 2 +- .../GlobalHotKeyManagerTests.swift | 136 +++++++++++++ .../HotKeyRecorderViewTests.swift | 179 ++++++++++++++++++ .../MenuBarControllerTests.swift | 72 +++++++ .../MouseEventGeneratorTests.swift | 4 +- .../MultitouchManagerTests.swift | 2 +- .../SystemGestureHelperTests.swift | 12 +- MiddleDrag/UI/HotKeyRecorderView.swift | 2 +- MiddleDrag/UI/MenuBarController.swift | 2 +- 13 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift create mode 100644 MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift diff --git a/MiddleDrag.xcodeproj/project.pbxproj b/MiddleDrag.xcodeproj/project.pbxproj index 5d7ffbc..3991501 100644 --- a/MiddleDrag.xcodeproj/project.pbxproj +++ b/MiddleDrag.xcodeproj/project.pbxproj @@ -92,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, @@ -132,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, 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..2838b48 --- /dev/null +++ b/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift @@ -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) } + } +} diff --git a/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift b/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift new file mode 100644 index 0000000..a78c543 --- /dev/null +++ b/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift @@ -0,0 +1,179 @@ +import XCTest +import Carbon.HIToolbox + +@testable import MiddleDragCore + +@MainActor @unsafe final class HotKeyRecorderViewTests: XCTestCase { + + // MARK: - Initialization Tests + + func testInitWithBinding() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + XCTAssertEqual(recorder.binding, binding) + XCTAssertFalse(recorder.title.contains("Press a key")) + } + + func testInitSetsBezelStyle() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(shiftKey)) + let recorder = HotKeyRecorderView(binding: binding) + + XCTAssertEqual(recorder.bezelStyle, .recessed) + } + + func testInitSetsMonospacedFont() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(shiftKey)) + let recorder = HotKeyRecorderView(binding: binding) + + XCTAssertNotNil(recorder.font) + } + + // MARK: - Binding Update Tests + + func testBindingUpdateChangesTitle() { + let initial = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: initial) + let titleBefore = recorder.title + + let updated = HotKeyBinding(keyCode: UInt32(kVK_ANSI_B), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + recorder.binding = updated + + // Title should change since the binding changed + XCTAssertNotEqual(recorder.title, titleBefore) + } + + func testBindingEquality() { + let a = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let b = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + XCTAssertEqual(a, b) + } + + func testBindingInequality() { + let a = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let b = HotKeyBinding(keyCode: UInt32(kVK_ANSI_M), carbonModifiers: UInt32(cmdKey)) + XCTAssertNotEqual(a, b) + } + + // MARK: - cancelRecording Tests + + func testCancelRecordingWhenNotRecordingDoesNotCrash() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + // Should be safe to call even when not recording + XCTAssertNoThrow(recorder.cancelRecording()) + } + + func testCancelRecordingAfterStartRecording() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + // Simulate clicking the button to start recording + recorder.performClick(nil) + + // Title should show recording state + XCTAssertEqual(recorder.title, "Press a key…") + + // Cancel should clean up + recorder.cancelRecording() + + // Title should revert to binding display string + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testCancelRecordingPreservesOriginalBinding() { + let original = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let recorder = HotKeyRecorderView(binding: original) + + // Start recording then cancel + recorder.performClick(nil) + recorder.cancelRecording() + + // Binding should remain unchanged + XCTAssertEqual(recorder.binding, original) + } + + func testDoubleCancelRecordingDoesNotCrash() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + recorder.performClick(nil) + recorder.cancelRecording() + // Second cancel should be safe + XCTAssertNoThrow(recorder.cancelRecording()) + } + + // MARK: - startRecording Idempotency + + func testDoubleClickDoesNotDoubleInstallMonitor() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + // Click twice - guard should prevent double installation + recorder.performClick(nil) + recorder.performClick(nil) + + // Cancel once should fully clean up + recorder.cancelRecording() + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + // MARK: - resignFirstResponder Tests + + func testResignFirstResponderStopsRecording() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + recorder.performClick(nil) + XCTAssertEqual(recorder.title, "Press a key…") + + // Simulate losing focus + _ = recorder.resignFirstResponder() + + // Should no longer be recording + XCTAssertNotEqual(recorder.title, "Press a key…") + } + + func testResignFirstResponderWhenNotRecordingDoesNotCrash() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + // Should be safe when not recording + let result = recorder.resignFirstResponder() + XCTAssertTrue(result) + } + + // MARK: - onBindingChanged Callback Tests + + func testOnBindingChangedNotCalledOnCancel() { + let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + let recorder = HotKeyRecorderView(binding: binding) + + var callbackCalled = false + recorder.onBindingChanged = { _ in callbackCalled = true } + + recorder.performClick(nil) + recorder.cancelRecording() + + XCTAssertFalse(callbackCalled) + } + + // MARK: - Deallocation Safety Tests + + func testDeallocAfterRecordingStartedDoesNotCrash() { + // This tests the deinit safety net + var recorder: HotKeyRecorderView? = HotKeyRecorderView( + binding: HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + ) + + // Start recording (installs monitor) + recorder?.performClick(nil) + + // Deallocate without calling cancelRecording - deinit should clean up + recorder = nil + + // If we get here without a crash, the deinit safety net worked + XCTAssertNil(recorder) + } +} diff --git a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift index bc641c1..74c4e2e 100644 --- a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift +++ b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift @@ -610,4 +610,76 @@ 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() + } } 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/UI/HotKeyRecorderView.swift b/MiddleDrag/UI/HotKeyRecorderView.swift index 949a78a..ad7a545 100644 --- a/MiddleDrag/UI/HotKeyRecorderView.swift +++ b/MiddleDrag/UI/HotKeyRecorderView.swift @@ -37,7 +37,7 @@ final class HotKeyRecorderView: NSButton { fatalError("init(coder:) not implemented") } - deinit { + 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 { diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index fb09d64..28f5123 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -18,7 +18,7 @@ public class MenuBarController: NSObject { } private weak var multitouchManager: MultitouchManager? private var preferences: UserPreferences - private var isMenuBarVisible = true + private(set) var isMenuBarVisible = true // Menu item tags for easy reference private enum MenuItemTag: Int { From 263a4d2d5b4cb1bb2c0a21a51db923e80749d8d9 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:13:25 -0700 Subject: [PATCH 6/8] 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. --- MiddleDrag/AppDelegate.swift | 2 +- .../GlobalHotKeyManagerTests.swift | 228 +++++++++++- .../HotKeyRecorderViewTests.swift | 349 ++++++++++++++---- .../MenuBarControllerTests.swift | 28 ++ MiddleDrag/UI/HotKeyRecorderView.swift | 1 + MiddleDrag/UI/MenuBarController.swift | 6 + 6 files changed, 532 insertions(+), 82 deletions(-) diff --git a/MiddleDrag/AppDelegate.swift b/MiddleDrag/AppDelegate.swift index 3089154..8fbf28d 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -168,7 +168,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - menuBarController?.toggleMenuBarVisibility() + menuBarController?.showMenuBarIcon() return false } diff --git a/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift b/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift index 2838b48..aeb80b2 100644 --- a/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/GlobalHotKeyManagerTests.swift @@ -27,11 +27,21 @@ import Carbon.HIToolbox XCTAssertEqual(result, UInt32(controlKey)) } - func testCarbonModifiersCombined() { + 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) @@ -43,12 +53,41 @@ import Carbon.HIToolbox XCTAssertEqual(result, 0) } - func testCarbonModifiersIgnoresNonModifierFlags() { - // .capsLock is not mapped by our utility - should not appear in result + 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() { @@ -57,9 +96,13 @@ import Carbon.HIToolbox XCTAssertTrue(a === b) } + func testSharedInstanceIsNotNil() { + XCTAssertNotNil(GlobalHotKeyManager.shared) + } + // MARK: - Register / Unregister Tests - func testRegisterReturnsNonZeroID() { + func testRegisterDoesNotCrash() { // Carbon RegisterEventHotKey may fail in CI (no window server), // but the method itself should not crash let id = GlobalHotKeyManager.shared.register( @@ -93,16 +136,38 @@ import Carbon.HIToolbox 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() { - // 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 + // 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), @@ -116,9 +181,53 @@ import Carbon.HIToolbox } } - // MARK: - Handler Invocation Tests + 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 testRegisterStoresHandler() { + func testRegisterStoresHandlerWithoutInvoking() { var handlerCalled = false let id = GlobalHotKeyManager.shared.register( keyCode: UInt32(kVK_ANSI_G), @@ -127,10 +236,109 @@ import Carbon.HIToolbox handlerCalled = true } - // We can't easily simulate a Carbon hotkey press in tests, - // but we verify the handler was stored (not called yet) + // 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 index a78c543..857e1ad 100644 --- a/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift +++ b/MiddleDrag/MiddleDragTests/HotKeyRecorderViewTests.swift @@ -5,141 +5,228 @@ import Carbon.HIToolbox @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 testInitWithBinding() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + 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 binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(shiftKey)) - let recorder = HotKeyRecorderView(binding: binding) - + let recorder = unsafe makeRecorder() XCTAssertEqual(recorder.bezelStyle, .recessed) } - func testInitSetsMonospacedFont() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(shiftKey)) - let recorder = HotKeyRecorderView(binding: binding) + 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 testBindingUpdateChangesTitle() { - let initial = HotKeyBinding(keyCode: UInt32(kVK_ANSI_A), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: initial) + func testBindingDidSetUpdatesTitle() { + let recorder = unsafe makeRecorder(keyCode: kVK_ANSI_A) let titleBefore = recorder.title - let updated = HotKeyBinding(keyCode: UInt32(kVK_ANSI_B), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) - recorder.binding = updated - - // Title should change since the binding changed + recorder.binding = unsafe makeBinding(keyCode: kVK_ANSI_B, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) XCTAssertNotEqual(recorder.title, titleBefore) } func testBindingEquality() { - let a = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) - let b = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + 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 testBindingInequality() { - let a = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let b = HotKeyBinding(keyCode: UInt32(kVK_ANSI_M), carbonModifiers: UInt32(cmdKey)) + func testBindingInequalityByKeyCode() { + let a = unsafe makeBinding(keyCode: kVK_ANSI_E) + let b = unsafe makeBinding(keyCode: kVK_ANSI_M) XCTAssertNotEqual(a, b) } - // MARK: - cancelRecording Tests + 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 testCancelRecordingWhenNotRecordingDoesNotCrash() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) + 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) + } - // Should be safe to call even when not recording - XCTAssertNoThrow(recorder.cancelRecording()) + 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) + } } - func testCancelRecordingAfterStartRecording() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) + // MARK: - startRecording Tests - // Simulate clicking the button to start recording + func testStartRecordingChangesTitle() { + let recorder = unsafe makeRecorder() recorder.performClick(nil) + XCTAssertEqual(recorder.title, "Press a key…") + } - // Title should show recording state + 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…") - // Cancel should clean up + // 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()) + } - // Title should revert to binding display string + 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 = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)) + let original = unsafe makeBinding(keyCode: kVK_ANSI_E, modifiers: UInt32(cmdKey) | UInt32(shiftKey)) let recorder = HotKeyRecorderView(binding: original) - // Start recording then cancel recorder.performClick(nil) recorder.cancelRecording() - // Binding should remain unchanged XCTAssertEqual(recorder.binding, original) } func testDoubleCancelRecordingDoesNotCrash() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) - + let recorder = unsafe makeRecorder() recorder.performClick(nil) recorder.cancelRecording() - // Second cancel should be safe XCTAssertNoThrow(recorder.cancelRecording()) } - // MARK: - startRecording Idempotency - - func testDoubleClickDoesNotDoubleInstallMonitor() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) + func testCancelRecordingRestoresTitleToDisplayString() { + let binding = unsafe makeBinding() let recorder = HotKeyRecorderView(binding: binding) + let expectedTitle = binding.displayString - // Click twice - guard should prevent double installation recorder.performClick(nil) - recorder.performClick(nil) - - // Cancel once should fully clean up recorder.cancelRecording() - XCTAssertNotEqual(recorder.title, "Press a key…") + + XCTAssertEqual(recorder.title, expectedTitle) } // MARK: - resignFirstResponder Tests func testResignFirstResponderStopsRecording() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) - + let recorder = unsafe makeRecorder() recorder.performClick(nil) XCTAssertEqual(recorder.title, "Press a key…") - // Simulate losing focus _ = recorder.resignFirstResponder() - - // Should no longer be recording XCTAssertNotEqual(recorder.title, "Press a key…") } - func testResignFirstResponderWhenNotRecordingDoesNotCrash() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) + 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) + } - // Should be safe when not recording + func testResignFirstResponderReturnsTrueAfterRecording() { + let recorder = unsafe makeRecorder() + recorder.performClick(nil) let result = recorder.resignFirstResponder() XCTAssertTrue(result) } @@ -147,9 +234,7 @@ import Carbon.HIToolbox // MARK: - onBindingChanged Callback Tests func testOnBindingChangedNotCalledOnCancel() { - let binding = HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - let recorder = HotKeyRecorderView(binding: binding) - + let recorder = unsafe makeRecorder() var callbackCalled = false recorder.onBindingChanged = { _ in callbackCalled = true } @@ -159,21 +244,143 @@ import Carbon.HIToolbox 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() { - // This tests the deinit safety net - var recorder: HotKeyRecorderView? = HotKeyRecorderView( - binding: HotKeyBinding(keyCode: UInt32(kVK_ANSI_E), carbonModifiers: UInt32(cmdKey)) - ) - - // Start recording (installs monitor) + var recorder: HotKeyRecorderView? = unsafe makeRecorder() recorder?.performClick(nil) - // Deallocate without calling cancelRecording - deinit should clean up + // 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) + } - // If we get here without a crash, the deinit safety net worked + 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 74c4e2e..92a77c0 100644 --- a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift +++ b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift @@ -682,4 +682,32 @@ import XCTest 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/UI/HotKeyRecorderView.swift b/MiddleDrag/UI/HotKeyRecorderView.swift index ad7a545..ccd59d9 100644 --- a/MiddleDrag/UI/HotKeyRecorderView.swift +++ b/MiddleDrag/UI/HotKeyRecorderView.swift @@ -42,6 +42,7 @@ final class HotKeyRecorderView: NSButton { // was never called (e.g. alert dismissed without resignFirstResponder) if let monitor = localMonitor { NSEvent.removeMonitor(monitor) + localMonitor = nil } } diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 28f5123..a30371b 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -839,6 +839,12 @@ public class MenuBarController: NSObject { 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) From e2ef4481273c0793b45d4933a618e488794bf844 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:23:32 -0700 Subject: [PATCH 7/8] 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. --- MiddleDrag/UI/MenuBarController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index a30371b..4c7e820 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -861,7 +861,9 @@ public class MenuBarController: NSObject { buildMenu() // Pop the menu open so the user knows it's back - if let button = statusItem.button { + // 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) } } From d3f3865a82c9f362f9a6c8f361d659bb4a52c262 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:30:26 -0700 Subject: [PATCH 8/8] 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. --- MiddleDrag/UI/MenuBarController.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 4c7e820..16c9e00 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -572,9 +572,10 @@ public class MenuBarController: NSObject { title: "Toggle MiddleDrag Hotkey", current: preferences.toggleHotKey ) { [weak self] newBinding in - self?.preferences.toggleHotKey = newBinding - NotificationCenter.default.post(name: .preferencesChanged, object: self?.preferences) - self?.buildMenu() + guard let self else { return } + self.preferences.toggleHotKey = newBinding + NotificationCenter.default.post(name: .preferencesChanged, object: self.preferences) + self.buildMenu() } } @@ -583,9 +584,10 @@ public class MenuBarController: NSObject { title: "Menu Bar Visibility Hotkey", current: preferences.menuBarHotKey ) { [weak self] newBinding in - self?.preferences.menuBarHotKey = newBinding - NotificationCenter.default.post(name: .preferencesChanged, object: self?.preferences) - self?.buildMenu() + guard let self else { return } + self.preferences.menuBarHotKey = newBinding + NotificationCenter.default.post(name: .preferencesChanged, object: self.preferences) + self.buildMenu() } }