Is there any way to allow this in a sandboxed environment?
Yes, but you have to take a slightly different tack. Rather than using an NSEvent global event monitor, use a CGEventTap. For weird historical reasons, the former requires the Accessibility privilege whereas the latter requires the Input Monitoring privilege. The Input Monitoring privilege is easily available to sandboxed apps, and even apps published on the Mac App Store. It even has APIs to check for (CGPreflightListenEventAccess) and explicitly request (CGRequestListenEventAccess) that privilege.
CGEventTap is not exactly fun to call from Swift. Pasted in below is a snippet that shows the basics. I pulled this out of a large test project, and removed some code that shouldn’t matter to you, so I apologise if this doesn’t compile out of the box.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
final class CGEventTapAction {
init(log: OSLog) {
self.log = log
}
let log: OSLog
private var runState: RunState? = nil
private struct RunState {
let port: CFMachPort
let setStatus: (String) -> Void
}
func start(_ setStatus: @escaping (String) -> Void) {
precondition(self.runState == nil)
os_log(.debug, log: self.log, "will create tap")
let info = Unmanaged.passRetained(self).toOpaque()
let mask = CGEventMask(1 << CGEventType.keyDown.rawValue)
guard let port = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mask,
callback: { (proxy, type, event, info) -> Unmanaged<CGEvent>? in
let obj = Unmanaged<CGEventTapAction>.fromOpaque(info!).takeUnretainedValue()
obj.didReceiveEvent(event)
// We don’t replace the event, so the new event is the same as
// the old event, so we return it unretained.
return Unmanaged.passUnretained(event)
},
userInfo: info
) else {
os_log(.debug, log: self.log, "did not create tap")
// We retained `self` above, but the event tap didn’t get created so
// we need to clean up.
Unmanaged<CGEventTapAction>.fromOpaque(info).release()
setStatus("Failed to create event tap.")
return
}
let rls = CFMachPortCreateRunLoopSource(nil, port, 0)!
CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, .defaultMode)
self.runState = RunState(port: port, setStatus: setStatus)
os_log(.debug, log: self.log, "did create tap")
}
private func didReceiveEvent(_ event: CGEvent) {
os_log(.debug, log: self.log, "did receive event")
guard let runState = self.runState else { return }
runState.setStatus("Last event at \(Date()).")
}
func stop() {
guard let runState = self.runState else { return }
self.runState = nil
os_log(.debug, log: self.log, "will stop tap")
CFMachPortInvalidate(runState.port)
// We passed a retained copy of `self` to the `info` parameter
// when we created the tap. We need to release that now that we’ve
// invalidated the tap.
Unmanaged.passUnretained(self).release()
os_log(.debug, log: self.log, "did stop tap")
}
}