TL;DR

I shipped a macOS menu bar app (QUICOPY) to the Mac App Store. Its core feature is global keyboard shortcuts that output text in any app. The obvious implementation — CGEvent.post() — is silently blocked by App Sandbox. Apple does not document this clearly. Here's what actually works, and why.

If you're building anything that simulates keyboard input on macOS and targeting the App Store, this will save you a week.


The Problem

I wanted a menu bar app where pressing ⌘⇧1 in any application types a piece of pre-set text at the cursor. TextExpander-style. Simple, right?

Two subproblems:

  1. Capture a global keyboard shortcut (even when my app is not focused)
  2. Output text into whatever app is focused

Both sound like 10-line solutions. Neither is, if you want to be on the Mac App Store.

Capturing Global Shortcuts: CGEvent Tap

This part is mostly fine. Use CGEvent.tapCreate with .cgSessionEventTap and listen for .keyDown events.

let eventMask = (1 << CGEventType.keyDown.rawValue)
let tap = CGEvent.tapCreate(
    tap: .cgSessionEventTap,
    place: .headInsertEventTap,
    options: .defaultTap,
    eventsOfInterest: CGEventMask(eventMask),
    callback: { _, type, event, _ in
        // Inspect event.flags and keycode here
        return Unmanaged.passUnretained(event)
    },
    userInfo: nil
)

Sandbox gotcha: CGEvent.tapCreate requires the user to grant your app Input Monitoring (or in some cases Accessibility) permission. This is fine — you get a permission prompt, user approves, done.

check_circle
This part works in a sandboxed Mac App Store app. You can monitor events at the session level as long as the user grants Input Monitoring permission. No entitlement file magic needed — the sandbox allows the system permission prompt to handle it.

Outputting Text: The Approach That Everyone Tries First

Naturally, you reach for CGEvent.post():

// Synthesize ⌘V
let src = CGEventSource(stateID: .combinedSessionState)
let cmdVDown = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true)
cmdVDown?.flags = .maskCommand
cmdVDown?.post(tap: .cgSessionEventTap)

Works flawlessly in a non-sandboxed development build. You celebrate.

Then you enable App Sandbox (required for App Store), run it, and... nothing happens. No error. No log. No permission prompt. The .post() call just silently does nothing.

The Undocumented Rule

CGEvent.post() is completely blocked inside App Sandbox. There is no entitlement that re-enables it. Apple will not provide one.

I spent two days reading every developer forum thread, every entitlement documentation page, every StackOverflow answer. The closest Apple documentation admits it is this line in the App Sandbox Design Guide, which you have to squint to find:

Sending synthetic events to other processes is disallowed.

That's it. One sentence. In a guide that most indie devs stop reading after the "Container Directory" section.

Workarounds I saw suggested online that do not work:

What Actually Works: AppleScript System Events

The workaround is to pretend to be an automation client and ask System Events (an Apple-signed helper) to type for you.

Strategy:

  1. Write the text into the clipboard (NSPasteboard)
  2. Send an AppleScript "System Events → keystroke ⌘V" via NSAppleScript
  3. Restore the previous clipboard contents
func outputText(_ text: String) {
    let pb = NSPasteboard.general
    let savedItems = pb.pasteboardItems?.compactMap { /* snapshot */ }

    pb.clearContents()
    pb.setString(text, forType: .string)

    let script = """
    tell application "System Events"
        keystroke "v" using command down
    end tell
    """

    var error: NSDictionary?
    NSAppleScript(source: script)?.executeAndReturnError(&error)

    // Restore clipboard after a short delay (let the paste complete)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        pb.clearContents()
        // re-write savedItems
    }
}

Required entitlement:

<key>com.apple.security.automation.apple-events</key>
<true/>

Required Info.plist entry (macOS 10.14+):

<key>NSAppleEventsUsageDescription</key>
<string>QUICOPY uses System Events to paste your shortcut text into the currently focused application.</string>

On first use, macOS shows the user a permission prompt: "QUICOPY wants permission to control System Events."

User clicks Allow → done. Works for all subsequent invocations.

The Latency Trade-off

CGEvent.post() (if it worked) would be ~5 ms. The AppleScript round-trip is 40–80 ms on a modern M-series Mac, closer to 100 ms on Intel.

For a text-expansion use case, this is invisible to the user — it feels instant. The whole flow (user releases shortcut → text appears) stays under 100 ms on Apple Silicon, which is the human perception threshold.

If you need true sub-10-ms latency (game input, accessibility apps), App Store distribution is probably not your path. Direct notarized distribution outside the store lifts the sandbox and lets you use CGEvent.post() directly.

The Clipboard Restoration Problem

The paste trick mutates the user's clipboard. If you don't restore it, users will paste your shortcut text into their next real Cmd+V — very bad UX.

Naive fix:

let saved = NSPasteboard.general.string(forType: .string)
// ... do paste ...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    NSPasteboard.general.setString(saved ?? "", forType: .string)
}

Problem: clipboard can hold multiple representations — images, rich text, file promises. A .string(forType:) snapshot throws all of that away.

Better:

let items = pb.pasteboardItems?.compactMap { item -> NSPasteboardItem? in
    let copy = NSPasteboardItem()
    for type in item.types {
        if let data = item.data(forType: type) {
            copy.setData(data, forType: type)
        }
    }
    return copy
}

// ... paste ...

DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
    pb.clearContents()
    if let items = items { pb.writeObjects(items) }
}

Iterate every NSPasteboardItem.types and copy the raw Data for each. Preserves everything including image clipboard, file references, and rich text.

The Timing Problem

If you restore the clipboard too fast, you restore before System Events has read it → the paste gets nothing.

If you restore too slowly, there's a visible window where the user's original clipboard is gone.

Empirical finding: 100 ms delay is safe on Apple Silicon. 150 ms on Intel. executeAndReturnError does not block until the paste is truly complete — it returns as soon as the AppleScript dispatch succeeds.

Do not rely on DispatchQueue.sync thinking it'll wait — it won't, the paste is asynchronous on System Events' side.

App Store Review Pitfalls

When I submitted to the App Store, the reviewer flagged Accessibility in my initial build. I had included Accessibility API calls as a fallback for when Automation permission wasn't granted.

Apple's position: If you can accomplish the task through a less-privileged mechanism (Automation), don't ask for the more-privileged one (Accessibility).

I removed all Accessibility code and replaced it with a non-blocking permission-request flow that only asks for Automation. Resubmitted. Approved.

check_circle
Lesson: Minimize entitlements. Each entitlement is a question the reviewer will ask "do you really need this?"

What the Final Architecture Looks Like

User presses ⌘⇧1
         │
         ▼
CGEvent Tap (captures system-wide keydown)
         │
         ▼
Match against user's shortcut mapping
         │
         ▼
Load text snippet → NSPasteboard
         │
         ▼
NSAppleScript: "keystroke v using command down"
         │
         ▼
macOS System Events types Cmd+V into focused app
         │
         ▼
Restore previous clipboard (100ms delay)

If You're Building Something Similar

About QUICOPY

I built QUICOPY — a menu bar app that does exactly what this post describes, plus 7 built-in AI prompt templates mapped to ⌘⇧1 through ⌘⇧7. $9.99 lifetime on the Mac App Store.

download Try QUICOPY on the Mac App Store

Questions or corrections? Email me — especially interested in hearing from anyone who's found a way to make CGEvent.post() work under sandbox (I don't think it's possible, but would love to be wrong).