C.W.K.
Stream
Lesson 04 of 05 · published

Don't Put a Tap in Everyone's Keyboard

~11 min · hotkey, event-tap, permissions, flagschanged

Level 0Unlit
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A suppressing event tap is a tollbooth on every keystroke the machine makes. Be very sure you want that job."

The Requirement

Firekeeper's default activation is a bare Fn hold — no chord, just hold the key you never use and talk. That's a lovely interaction and a genuinely awkward thing to implement, because a bare modifier isn't a normal hotkey. There's no "Fn pressed" event in the ordinary shortcut APIs; modifiers announce themselves through flag changes, and you have to reconstruct press-and-hold from that.

The Tempting Wrong Tool

The obvious answer is a CGEventTap: sit in the system-wide event stream, watch every key, and swallow the ones you claim. It works — and it makes your app a tollbooth on every keystroke the machine produces. If your tap misbehaves, you don't break your app; you break typing, everywhere. That's not hypothetical: a competitor's suppressing tap is exactly what ate users' spacebars. On top of the risk, a tap needs Input Monitoring permission — a second scary prompt on top of the Accessibility grant you already need for insertion.

CGEventTap (rejected):        flagsChanged + keyState (chosen):
  sits in every keystroke       observes modifier flag changes only
  can suppress system-wide      cannot swallow anyone's keys
  needs Input Monitoring        rides the Accessibility trust we already have
  competitor's ate spacebars    can't eat what it doesn't intercept

The Chosen Path

Firekeeper watches NSEvent flagsChanged monitors instead, and uses CGEventSource.keyState to disambiguate the cases flags alone can't — most notably telling a press from a release when the twin modifier (the other Command key, say) is still held down. It works with the Accessibility trust the app already holds for insertion, adds no new permission surface, and structurally cannot swallow a keystroke, because it never intercepts the stream in the first place. Same interaction, a fraction of the blast radius.

Prefer observation over interception. When you need to know about system events, watching is dramatically safer than standing in the path. An observer that misbehaves logs a wrong value; an interceptor that misbehaves breaks the system for every app. Reach for interception only when you genuinely must consume the event — and for a push-to-talk key, you don't.
Fn double-fires as the 🌐 key. On modern macOS the Fn key is also the Globe key, so a bare Fn hold can trigger the system's emoji/input-source picker on top of your dictation. There's no code fix — the user sets 'Press 🌐 key to: Do Nothing' in System Settings. Which is why every binding is customizable and the Setup panel surfaces the hint: an interaction that depends on an OS setting must tell the user about that setting.

Code

Observe modifier flags; never intercept the stream·swift
// Watch flag changes — we observe, we never consume.
NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { event in
    let fnHeld = event.modifierFlags.contains(.function)

    // flagsChanged alone can't always tell press from release when the
    // twin modifier is still down — keyState disambiguates.
    let physicallyDown = CGEventSource.keyState(.combinedSessionState,
                                                key: CGKeyCode(kVK_Function))
    switch (fnHeld, physicallyDown) {
    case (true, true):   controller.start(mode: .dictate)
    case (false, false): Task { await controller.stop() }
    default: break
    }
}
// No CGEventTap. We cannot swallow a key we never intercept.

External links

Exercise

Find a case where an app needs system-wide awareness of something (keystrokes, clipboard, window changes). Ask whether it needs to OBSERVE or INTERCEPT. What's the blast radius of a bug in each design, and what extra permission does interception cost you?
Hint
Observation's worst bug is that your app misses or mis-reads an event — the damage is contained to you. Interception's worst bug is that the whole OS loses the event — the damage is everyone else's typing. Interception also typically demands a broader, scarier permission, so you pay twice: more risk and more friction at install.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.