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:
- Capture a global keyboard shortcut (even when my app is not focused)
- 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.
✅ This part works in a sandboxed Mac App Store app.
You can monitor events at the session level as long as the user grants the permission. The permission is called "Input Monitoring" in macOS 10.15+ and it specifically covers event taps.
No entitlement file magic needed for input monitoring — 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.
The workarounds I saw suggested online, which do not work:
- ❌ Adding
com.apple.security.temporary-exception.user-interaction— deprecated, no longer honored - ❌ Adding
com.apple.security.device.input— wrong entitlement, not related - ❌ Using
HIDPostAuxKey— same sandbox rule applies - ❌ Using
IOHIDPostEvent— same - ❌ Requesting Accessibility permission — has no effect on this
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:
- Write the text into the clipboard (
NSPasteboard) - Send an AppleScript "System Events → keystroke ⌘V" via
NSAppleScript - 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.
✅ 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)
Total latency: ~80–100 ms on M-series.
Total code: ~200 lines Swift.
App binary size: 2.8 MB (no Electron).
App Store: approved.
If You're Building Something Similar
- Read the App Sandbox Design Guide twice. The fine print matters.
- Don't assume CGEvent.post works, even if your dev build works. Test with sandbox enabled early.
- Snapshot clipboard as pasteboard items, not strings.
-
Add a permission request UI for
com.apple.security.automation.apple-events. Without it, the AppleScript fails silently the first time. - Minimize entitlements. Every one is a review friction point.
- Consider whether you really need App Store. Direct notarized distribution gives you more freedom but less reach.
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. It's on the Mac App Store for $9.99 lifetime or $1.99/month.
If you want to see the end result of all this sandbox wrestling, there it is.
Questions or corrections? Happy to discuss in the comments — 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).
Originally posted on quicopy.com. Follow me for more macOS indie dev notes.
Top comments (0)