Accessibility permission in sandboxed app

Is it possible to create a sandboxed app that uses accessibility permission? And if so, how do I ask the user for that permission in a way that is allowed by the App Store?

Im creating a small menubar app and my current (rejected) solution is to create a pop-up, with link to Security & Privacy > Accessibility and the pop-up asks the user to manually add the app to the list and check the checkbox. This works in sandbox.

Reason for rejection:

"Specifically, your app requires to grant accessibility access, but once we opened the accessibility settings, your app was not listed."

I know it's not listed there and it has to be added manually. But its the only solution I've found to this issue. Is there perhaps any way to add the app there programmatically?

Im a bit confused since I've seen other apps in App Store that work the same way, where you have to add the app to the list manually. Eg. Flycut. 🤷‍♂️

I know about this alternative solution, and it's not allowed in sandboxed apps. It also adds the app to the accessibility list automagically:

func getPermission() {
AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue():true] as CFDictionary). 
}

Does anyone have a solution for this?

Best regards, Daniel

Answered by DTS Engineer in 716892022

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")
    }
}

Why specifically do you need the Accessibility privilege? That is, what data are you getting, or actions are you doing, that you can’t do without it?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thank you for your reply! Im creating a small lorem ipsum text generator app that requires an observer for keyDown events. And this requires accessibility permission. So the data Im getting is essentially text from keyboard. The action I do in the app is to match the input text with regexp in order to trigger commands.

NSEvent.addGlobalMonitorForEvents(matching: .keyDown) {(event) in guard let characters = event.characters else{return}

In case you want a further explanation I’ve described the app properly over here: https://danieldanielsson.se/#/lorema

Is there any way to allow this in a sandboxed environment?

Accepted Answer

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")
    }
}

I'm looking at this thread with interest, because I'm looking into implementing proper keyboard shortcuts that don't depend on Carbon APIs.
I think I will manage to get the code working, but I'm struggling with the CGRequestListenEventAccess step. I'm calling this method, but nothing is happening at all. The documentation for this function is absent. Could you give some pointers?

I did a reset of the permissions of my app, and that triggered a prompt: tccutil reset All com.mycompany.myapp

I'm going through your example code, and while you said it probably won't compile (which is fine), I'm struggling with the class CGEventTapAction you're using. I can't find in the documentation.

I can't find in the documentation.

CGEventTapAction is my class name, that is, it’s the name of the class that I’ve created to encapsulate this functionality. The relevant API is CGEvent.tapCreate(…), which you can find documented here.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Accessibility permission in sandboxed app
 
 
Q