swiftmultithreadingcocoaswift3cgeventtap

Swift 3 CFRunLoopRun in Thread?


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

}

Solution

  • 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.