Skip to content
C.W.K.
Stream
Lesson 04 of 07 · published

Don't Put a Tap in Everyone's Keyboard

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

Level 0Unlit
0 XP0/48 lessons0/13 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. And the permission does not warn you: a suppressing tap is authorized by the same Accessibility grant you already hold for insertion, so that grant quietly becomes a seat in every keystroke. (A listen-only keyboard tap is the kind that needs Input Monitoring, a second prompt; a suppressing one needs nothing new, which is its own kind of danger.)

CGEventTap (rejected):        flagsChanged + keyState (chosen):
  sits in every keystroke       observes modifier flag changes only
  can suppress system-wide      cannot swallow anyone's keys
  same grant, far more power    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.
let fnKey = CGKeyCode(kVK_Function)

func handleFlags(_ event: NSEvent) {
    // flagsChanged fires for EVERY modifier; act only on the Fn key's own event.
    guard event.keyCode == fnKey else { return }
    // Press and release share one keyCode; the physical key state says which,
    // because flag presence alone lies when a twin modifier is still held.
    if CGEventSource.keyState(.combinedSessionState, key: fnKey) {
        controller.start(mode: .dictate)
    } else {
        Task { await controller.stop() }
    }
}
// Global: another app is in front. Local: one of our own windows is.
NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { handleFlags($0) }
NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged]) { handleFlags($0); return $0 }
// 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 does interception make of the permission it rides?
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. And interception makes more of whatever permission it rides — on macOS a suppressing tap turns the Accessibility grant into a seat in every keystroke — so you pay twice: more risk, and a grant that now means far more than the user thinks.

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 in — Please sign in to comment.

No comments yet — be the first.