Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion MiddleDrag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,7 +110,6 @@
membershipExceptions = (
Debug.xcconfig,
Release.xcconfig,
Secrets.xcconfig,
);
target = 1A0000011 /* MiddleDrag */;
};
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 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 Down Expand Up @@ -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()

Expand Down
33 changes: 32 additions & 1 deletion MiddleDrag/UI/MenuBarController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Cocoa
import Carbon.HIToolbox

/// Manages the menu bar UI and user interactions
@MainActor
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -470,7 +473,7 @@ public class MenuBarController: NSObject {
buildMenu()
}

@objc func toggleEnabled() {
@objc public func toggleEnabled() {
multitouchManager?.toggleEnabled()
let isEnabled = multitouchManager?.isEnabled ?? false

Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions MiddleDrag/Utilities/GlobalHotKeyManager.swift
Original file line number Diff line number Diff line change
@@ -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<GlobalHotKeyManager>
.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
}
}
Loading