0

I just made a simple testing app to display keycode of keystrokes along with modifiers. It works fine for 3 keystrokes, then the app crashes. When it crashes, debug console just shows (LLDB) at the end. Any suggestion what might be causing this? Maybe something has to do with thread or pointer, but I'm not sure how I can fix this. I'm including the code below. I'd really appreciate any help! Thanks!

import Cocoa
import Foundation

class ViewController: NSViewController {

    @IBOutlet weak var textField: NSTextFieldCell!
    let speech:NSSpeechSynthesizer = NSSpeechSynthesizer()

    func update(msg:String) {
        textField.stringValue = msg
        print(msg)
        speech.startSpeaking(msg)
    }

    func bridgeRetained<T : AnyObject>(obj : T) -> UnsafeRawPointer {
        return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque())
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        DispatchQueue.global().async {
            func myCGEventCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {

                let parent:ViewController = Unmanaged<ViewController>.fromOpaque(refcon!).takeRetainedValue()

                if [.keyDown].contains(type) {
                    let flags:CGEventFlags =     event.flags
                    let pressed = Modifiers(rawValue:flags.rawValue)
                    var msg = ""

                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskAlphaShift.rawValue)) {
                        msg+="caps+"
                    }
                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskShift.rawValue)) {
                        msg+="shift+"
                    }
                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskControl.rawValue)) {
                        msg+="control+"
                    }
                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskAlternate.rawValue)) {
                        msg+="option+"
                    }
                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskCommand.rawValue)) {
                        msg += "command+"
                    }
                    if pressed.contains(Modifiers(rawValue:CGEventFlags.maskSecondaryFn.rawValue)) {
                        msg += "function+"
                    }

                    var keyCode = event.getIntegerValueField(.keyboardEventKeycode)
                    msg+="\(keyCode)"

                    DispatchQueue.main.async {
                        parent.update(msg:msg)
                    }

                    if keyCode == 0 {
                        keyCode = 6
                    } else if keyCode == 6 {
                        keyCode = 0
                    }

                    event.setIntegerValueField(.keyboardEventKeycode, value: keyCode)
                }
                return Unmanaged.passRetained(event)
            }

            let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)

            guard let eventTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(eventMask), callback:  myCGEventCallback, userInfo: UnsafeMutableRawPointer(mutating: self.bridgeRetained(obj: self))) else {
                print("failed to create event tap")
                exit(1)
            }
            let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
            CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
            CGEvent.tapEnable(tap: eventTap, enable: true)
            CFRunLoopRun()
        }
        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
            // Update the view, if already loaded.
        }
    }

}
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
jl303
  • 1,461
  • 15
  • 27

1 Answers1

2

The main problem is the reference counting: You create a retained reference to the view controller when installing the event handler, this happens exactly once. Then you consume a reference in the callback, this happens for every tap event. Therefore the reference count drops to zero eventually and the view controller is deallocated, causing a crash.

Better pass unretained references to the callback, and take care that the event handler is uninstalled when the view controller is deallocated.

Also there is no need to create a separate runloop for an OS X application, or to asynchronously dispatch the handler creation.

Make the callback a global function, not a method. Use takeUnretainedValue() to get the view controller reference:

func myCGEventCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {

    let viewController = Unmanaged<ViewController>.fromOpaque(refcon!).takeUnretainedValue()
    if type == .keyDown {

        var keyCode = event.getIntegerValueField(.keyboardEventKeycode)
        let msg = "\(keyCode)"

        DispatchQueue.main.async {
            viewController.update(msg:msg)
        }

        if keyCode == 0 {
            keyCode = 6
        } else if keyCode == 6 {
            keyCode = 0
        }
        event.setIntegerValueField(.keyboardEventKeycode, value: keyCode)
    }
    return Unmanaged.passRetained(event)
}

In the view controller, keep a reference to the run loop source so that you can remove it in deinit, and use passUnretained() to pass a pointer to the view controller to the callback:

class ViewController: NSViewController {

    var eventSource: CFRunLoopSource?

    override func viewDidLoad() {
        super.viewDidLoad()

        let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
        let userInfo = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())

        if let eventTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
                                         options: .defaultTap, eventsOfInterest: CGEventMask(eventMask),
                                         callback: myCGEventCallback, userInfo: userInfo) {
            self.eventSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
            CFRunLoopAddSource(CFRunLoopGetCurrent(), self.eventSource, .commonModes)
        } else {
            print("Could not create event tap")
        }
    }

    deinit {
        if let eventSource = self.eventSource {
            CFRunLoopRemoveSource(CFRunLoopGetCurrent(), eventSource, .commonModes)
        }
    }

    // ...

}

Another option would be to install/uninstall the event handler in viewDidAppear and viewDidDisappear.

Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • Thank you so much for such detail answer! – jl303 Nov 20 '16 at 18:02
  • Actually I just revised the code, but now it doesn't seem to trigger the event, and it doesn't throw any error either. It seems I can't paste entire code with the right format using the comment here. Would you be kind to look at the revised code? Sorry I just started learning swift. http://pastebin.com/raw/tBJKnrKs – jl303 Nov 20 '16 at 18:09
  • @jl303: It did work in my test, but I had to run the application as root, otherwise the tap creation fails. It is documented that `tapCreate` only works if running as root or if access for assistive devices is enabled. – Martin R Nov 20 '16 at 18:10
  • Thank you again for the quick response! I added xCode under accessibility privacy category in the security/privacy system preference. The GUI comes up and everything when I run from xCode. It just doesn't update the label nor speak now. Before, it updated the label, but failed after 3 keystrokes. Does my code installs the CGEventap correctly? – jl303 Nov 20 '16 at 18:22
  • @jl303: Remove the `DispatchQueue.global().async`, as I suggested. – Martin R Nov 20 '16 at 19:06