52

I'm trying to have a handler in my Mac OS X app written in Swift for a global (system-wide) hotkey combo but I just cannot find proper documentation for it. I've read that I'd have to mess around in some legacy Carbon API for it, is there no better way? Can you show me some proof of concept Swift code? Thanks in advance!

Sindre Sorhus
  • 62,972
  • 39
  • 168
  • 232
Csaba Okrona
  • 693
  • 1
  • 6
  • 8
  • What exactly have you tried? What do you mean by hot keys? Have you tried NSEvent global monitor or CGEventTap – uchuugaka Apr 22 '15 at 13:58

7 Answers7

14

Since Swift 2.0, you can now pass a function pointer to C APIs.

var gMyHotKeyID = EventHotKeyID()
gMyHotKeyID.signature = OSType("swat".fourCharCodeValue)
gMyHotKeyID.id = UInt32(keyCode)

var eventType = EventTypeSpec()
eventType.eventClass = OSType(kEventClassKeyboard)
eventType.eventKind = OSType(kEventHotKeyPressed)

// Install handler.
InstallEventHandler(GetApplicationEventTarget(), {(nextHanlder, theEvent, userData) -> OSStatus in
    var hkCom = EventHotKeyID()
    GetEventParameter(theEvent, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, sizeof(EventHotKeyID), nil, &hkCom)

    // Check that hkCom in indeed your hotkey ID and handle it.
}, 1, &eventType, nil, nil)

// Register hotkey.
let status = RegisterEventHotKey(UInt32(keyCode), UInt32(modifierKeys), gMyHotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
pkamb
  • 33,281
  • 23
  • 160
  • 191
Charlie Monroe
  • 1,210
  • 9
  • 24
  • 3
    Does not work in `Swift 4` and without a more detailed explanation it’s impossible to implement. – ixany Nov 02 '17 at 19:02
  • 6
    Works well in Swift 4, I have converted my projects to Swift 4 and it works fine. As a developer, you should know that "doesn't work" is the worst possible description of an issue - what does "doesn't work" mean? It doesn't compile? What compile error are you getting? There's really not much to explain - each function used is documented by Apple. – Charlie Monroe Nov 03 '17 at 06:09
  • 3
    Good vibes only, Charlie! It would be great if you could share your Swift 4 solution as well. The solution you posted is incomplete what makes it hard for beginners to adapt. One thing that would be helpful to say is that importing `Carbon` is necessary. The next problem I faced is that `String` does not have a `.fourCharCodeValue` property. Also it would be great if you could provide an example for `keyCode` and `modifierKeys`. – ixany Nov 03 '17 at 09:17
  • 3
    @ixany - sorry if it sounded hostile - was not meant to be at all. `fourCharCodeValue` converts a string to `Int` - it was used a lot back in the day - 4 ASCII chars convert to 32-bit integer - it makes it easier to identify in dumps than a random integer. You can find my implementation here: https://github.com/charlieMonroe/XUCore/blob/master/XUCore/additions/StringExtensions.swift - I'm currently retrieving `keyCode` from `SRRecorderControl`: https://gist.github.com/charlieMonroe/0923985c704695b252ed9bb879e7f99d – Charlie Monroe Nov 06 '17 at 05:34
  • 1
    I have done my homework and made this solution to work with Swift 5. See [here](https://stackoverflow.com/a/58225397/598057). – Stanislav Pankevich Oct 03 '19 at 19:05
14

The following code works for me for Swift 5.0.1. This solution is the combination of the solution from the accepted answer by Charlie Monroe and the recommendation by Rob Napier to use DDHotKey.

DDHotKey seems to work out of the box but it had one limitation that I had to change: the eventKind is hardcoded to kEventHotKeyReleased while I needed both kEventHotKeyPressed and kEventHotKeyReleased event types.

eventSpec.eventKind = kEventHotKeyReleased;

If you want to handle both Pressed and Released events, just add a second InstallEventHandler call which registers the other event kind.

This the complete example of the code that registers the "Command + R" key for the kEventHotKeyReleased type.

import Carbon

extension String {
  /// This converts string to UInt as a fourCharCode
  public var fourCharCodeValue: Int {
    var result: Int = 0
    if let data = self.data(using: String.Encoding.macOSRoman) {
      data.withUnsafeBytes({ (rawBytes) in
        let bytes = rawBytes.bindMemory(to: UInt8.self)
        for i in 0 ..< data.count {
          result = result << 8 + Int(bytes[i])
        }
      })
    }
    return result
  }
}

class HotkeySolution {
  static
  func getCarbonFlagsFromCocoaFlags(cocoaFlags: NSEvent.ModifierFlags) -> UInt32 {
    let flags = cocoaFlags.rawValue
    var newFlags: Int = 0

    if ((flags & NSEvent.ModifierFlags.control.rawValue) > 0) {
      newFlags |= controlKey
    }

    if ((flags & NSEvent.ModifierFlags.command.rawValue) > 0) {
      newFlags |= cmdKey
    }

    if ((flags & NSEvent.ModifierFlags.shift.rawValue) > 0) {
      newFlags |= shiftKey;
    }

    if ((flags & NSEvent.ModifierFlags.option.rawValue) > 0) {
      newFlags |= optionKey
    }

    if ((flags & NSEvent.ModifierFlags.capsLock.rawValue) > 0) {
      newFlags |= alphaLock
    }

    return UInt32(newFlags);
  }

  static func register() {
    var hotKeyRef: EventHotKeyRef?
    let modifierFlags: UInt32 =
      getCarbonFlagsFromCocoaFlags(cocoaFlags: NSEvent.ModifierFlags.command)

    let keyCode = kVK_ANSI_R
    var gMyHotKeyID = EventHotKeyID()

    gMyHotKeyID.id = UInt32(keyCode)

    // Not sure what "swat" vs "htk1" do.
    gMyHotKeyID.signature = OSType("swat".fourCharCodeValue)
    // gMyHotKeyID.signature = OSType("htk1".fourCharCodeValue)

    var eventType = EventTypeSpec()
    eventType.eventClass = OSType(kEventClassKeyboard)
    eventType.eventKind = OSType(kEventHotKeyReleased)

    // Install handler.
    InstallEventHandler(GetApplicationEventTarget(), {
      (nextHanlder, theEvent, userData) -> OSStatus in
      // var hkCom = EventHotKeyID()

      // GetEventParameter(theEvent,
      //                   EventParamName(kEventParamDirectObject),
      //                   EventParamType(typeEventHotKeyID),
      //                   nil,
      //                   MemoryLayout<EventHotKeyID>.size,
      //                   nil,
      //                   &hkCom)

      NSLog("Command + R Released!")

      return noErr
      /// Check that hkCom in indeed your hotkey ID and handle it.
    }, 1, &eventType, nil, nil)

    // Register hotkey.
    let status = RegisterEventHotKey(UInt32(keyCode),
                                     modifierFlags,
                                     gMyHotKeyID,
                                     GetApplicationEventTarget(),
                                     0,
                                     &hotKeyRef)
    assert(status == noErr)
  }
}
Stanislav Pankevich
  • 11,044
  • 8
  • 69
  • 129
  • 1
    This still works great with Swift 5.7 unlike other solutions like https://github.com/soffes/HotKey. Unfortunately, there is a complete black hole when it comes to official documentation on this matter (global hotkeys). Thank you very much! – AlexeyGy Dec 25 '22 at 22:20
  • Could you please share usage example? – snake302 Feb 04 '23 at 13:55
  • Awesome solution! @snake302 you can simply register it in `applicationDidFinishLaunching` for instance. `HotkeySolution.register()` – Nash Equilibrium May 03 '23 at 02:33
  • how to make this work for cmd + tab, I have tested this for different characters its working – jan_kiran Jun 16 '23 at 11:31
12

I don't believe you can do this in 100% Swift today. You'll need to call InstallEventHandler() or CGEventTapCreate(), and both of those require a CFunctionPointer, which can't be created in Swift. Your best plan is to use established ObjC solutions such as DDHotKey and bridge to Swift.

You can try using NSEvent.addGlobalMonitorForEventsMatchingMask(handler:), but that only makes copies of events. You can't consume them. That means the hotkey will also be passed along to the currently active app, which can cause problems. Here's an example, but I recommend the ObjC approach; it's almost certainly going to work better.

let keycode = UInt16(kVK_ANSI_X)
let keymask: NSEventModifierFlags = .CommandKeyMask | .AlternateKeyMask | .ControlKeyMask

func handler(event: NSEvent!) {
    if event.keyCode == self.keycode &&
        event.modifierFlags & self.keymask == self.keymask {
            println("PRESSED")
    }
}

// ... to set it up ...
    let options = NSDictionary(object: kCFBooleanTrue, forKey: kAXTrustedCheckOptionPrompt.takeUnretainedValue() as NSString) as CFDictionaryRef
    let trusted = AXIsProcessTrustedWithOptions(options)
    if (trusted) {
        NSEvent.addGlobalMonitorForEventsMatchingMask(.KeyDownMask, handler: self.handler)
    }

This also requires that accessibility services be approved for this app. It also doesn't capture events that are sent to your own application, so you have to either capture them with your responder chain, our use addLocalMointorForEventsMatchingMask(handler:) to add a local handler.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • While it's usable, it is important to remember that the user is granting your app the ability to monitor every single key stroke, which is potentially dangerous, mainly if your app is not sandboxed and can be injected. The old Carbon API will do this just for the user-defined combinations, making them a "safer" solution. – Charlie Monroe Nov 03 '17 at 06:11
  • `InstallEventHandler()` works very well in Swift (at least since Swift 3). I'm using a Swift version of `DDHotKeyCenter` – vadian Jul 06 '19 at 16:41
9

A quick Swift 3 update for the setup:

let opts = NSDictionary(object: kCFBooleanTrue, forKey: kAXTrustedCheckOptionPrompt.takeUnretainedValue() as NSString) as CFDictionary

guard AXIsProcessTrustedWithOptions(opts) == true else { return }

NSEvent.addGlobalMonitorForEvents(matching: .keyDown, handler: self.handler)
pkamb
  • 33,281
  • 23
  • 160
  • 191
Teo Sartori
  • 1,082
  • 14
  • 24
  • Note: The Carbon-based Hotkey API does not need access privileges (can't be on the Mac App Store), although this sure is simpler to implement. – ctietze Feb 18 '17 at 08:16
  • There are apps like CopyLess 2 that listen for keyDown and don't require accessibility, do you know something about them? Maybe the apps from store don't require it? – Cristi Băluță Mar 03 '17 at 12:46
5

I maintain this Swift package that makes it easy to both add global keyboard shortcuts to your app and also let the user set their own.

import SwiftUI
import KeyboardShortcuts

// Declare the shortcut for strongly-typed access.
extension KeyboardShortcuts.Name {
    static let toggleUnicornMode = Self("toggleUnicornMode")
}

@main
struct YourApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            // …
        }
        Settings {
            SettingsScreen()
        }
    }
}

@MainActor
final class AppState: ObservableObject {
    init() {
        // Register the listener.
        KeyboardShortcuts.onKeyUp(for: .toggleUnicornMode) { [self] in
            isUnicornMode.toggle()
        }
    }
}

// Present a view where the user can set the shortcut they want.
struct SettingsScreen: View {
    var body: some View {
        Form {
            HStack(alignment: .firstTextBaseline) {
                Text("Toggle Unicorn Mode:")
                KeyboardShortcuts.Recorder(for: .toggleUnicornMode)
            }
        }
    }
}

SwiftUI is used in this example, but it also supports Cocoa.

Sindre Sorhus
  • 62,972
  • 39
  • 168
  • 232
2

Take a look at the HotKey Library. You can simply use Carthage to implement it into your own app. HotKey Library

Marian Nasry
  • 821
  • 9
  • 22
André Kuhlmann
  • 4,378
  • 3
  • 23
  • 42
2

there is a pretty hacky, but also pretty simple workaround if your app has a Menu:

  • add a new MenuItem (maybe call it something like "Dummy for Hotkey")
  • in the attributes inspector, conveniently enter your hotkey in the Key Equivalent field
  • set Allowed when Hidden, Enabled and Hidden to true
  • link it with an IBAction to do whatever your hotkey is supposed to do

done!

jaynetics
  • 1,234
  • 12
  • 15
  • Interesting approach, but it does not work for apps that live only in the menu bar: for the keyboard shortcut to be detected, the app must be activated somehow, for instance by clicking on it in the menu bar. – cdf1982 Nov 25 '20 at 14:53