Author: Arthur Li

  • Clipboard Cycling in Terminals, without Accessibility

    ClipRing is a clipboard history manager for Mac and iPhone. Its Mac party trick is ⇧⌘V: press it and the text you just pasted is replaced in place by the previous item in your history. Press again, it’s the one before that. A little [3/7] badge counts along. No popup, no picker — you just cycle until the thing you wanted is under your cursor.

    And it works inside terminals: iTerm2, Terminal.app, Warp, kitty. Type a command, paste the wrong snippet, hit ⇧⌘V, and the wrong snippet is erased and replaced on your shell’s command line.

    If this sounds familiar, you’ve probably used Emacs: the idea is lifted straight from the kill ring. Yank with C-y, then M-y swaps the yank for earlier kills, cycling back through everything you’ve killed. ⇧⌘V is M-y for every Mac app — and it’s where the “ring” in ClipRing comes from. If you have M-y muscle memory, you already know how to use ClipRing.

    Making that work in a sandboxed Mac App Store app turned out to be a tour of macOS’s least-documented corners. Here’s the map, so you don’t have to draw it yourself.

    Dead end #1: Accessibility

    Many clipboard utilities watch global hotkeys with NSEvent.addGlobalMonitorForEvents(matching: .keyDown). That API requires the Accessibility permission — and here’s the part nobody tells you: a sandboxed Mac App Store app can’t rely on obtaining it. In my testing the AXIsProcessTrustedWithOptions prompt never appeared and the app couldn’t add itself to the Accessibility list. Apple’s own DTS guidance for this situation is blunt: don’t use an NSEvent global monitor; use a listen-only CGEventTap, because — “for weird historical reasons” — the former falls under Accessibility while the latter relies on the Input Monitoring permission, which sandboxed Mac App Store apps can request.

    So: a listen-only event tap.

    let tap = CGEvent.tapCreate(
        tap: .cgSessionEventTap,
        place: .headInsertEventTap,
        options: .listenOnly,              // ← the important part
        eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue),
        callback: tapCallback,
        userInfo: refcon)

    .listenOnly matters because it creates a passive event tap that only observes events — that’s the configuration Apple DTS recommends for sandboxed Mac App Store apps. Active taps, which can modify or suppress events, are subject to different security restrictions and aren’t an appropriate replacement here. It also means you can never consume a keystroke. ClipRing used to eat the Escape key when you cancelled a cycle; now Escape both cancels the cycle and reaches your app. Fortunately, relatively few Mac apps assign a meaning to ⇧⌘V, so in practice the tradeoff has been acceptable. That’s the tax, and it’s worth paying.

    The permission API lies to running processes

    Three traps around the Input Monitoring grant, each of which cost me a TestFlight build:

    1. In my testing, CGPreflightListenEventAccess() never reflected a newly granted permission until the process restarted. It returned false at launch and continued returning false even after the user flipped the toggle in System Settings. The only live check that worked was attempting tapCreate and seeing whether you get a tap back.
    2. In my testing, a tap created before the grant never became active. Recreating it after the permission changed immediately fixed the problem. ClipRing retries on every didBecomeActive — the moment the user comes back from System Settings, the tap goes live, no relaunch.
    3. macOS disables your tap behind your back. Block the callback too long and you get .tapDisabledByTimeout; certain secure input triggers .tapDisabledByUserInput. Handle both by re-enabling, or key observation silently dies and your users file “it stopped working” reviews.

    One more: your own synthetic events come back through your own tap. ClipRing stamps every keystroke it posts with a magic number in .eventSourceUserData and skips them in the callback — otherwise the paste you inject looks like the user typing.

    Erasing text on a shell command line, reliably

    Cycling in a terminal means: erase what the previous paste put on the command line, then paste the next item. “Erase” = send N DEL (0x7F) key events, where N is the character count of the previous paste (grapheme clusters — Swift’s String.count — because shells backspace per user-perceived character, even for emoji that occupy two columns). In practice this matches readline, zsh, and the terminals I tested, though cursor behavior around complex Unicode isn’t perfectly consistent across all terminal emulators.

    Each of these details was a bug first:

    • The DEL events must carry an explicit Unicode string. A CGEventSource with .privateState has no keyboard layout, so its events have empty translated-characters. Terminal.app doesn’t care (it uses the keycode); iTerm2 calls [event characters] to decide what byte to write to the pty — empty string, nothing written, backspace silently swallowed. Fix: keyboardSetUnicodeString with 0x7F on every event.
    • The events must come from a .privateState source. Post them from .hidSystemState and they inherit the Cmd+Shift the user is physically holding (they’re mid-⇧⌘V!), turning each backspace into Cmd+Shift+Delete — a completely different action.
    • Create the event source once and reuse it. Repeatedly creating new CGEventSource instances eventually caused event creation to start failing in long-running sessions — CGEventSource(stateID:) began returning nil, and the “backspaces” started inheriting live modifiers again. Reusing a single source eliminated the problem. I haven’t found Apple documentation explaining why, but the behavior was consistently reproducible on my test machines. This one took days to see.

    The bracketed-paste ordering race

    The subtlest bug: ClipRing originally inserted the replacement text via the Accessibility API (AXUIElementSetAttributeValue on the focused element) to dodge a clipboard race. But AX insertion travels over Accessibility IPC, while the backspaces travel through the kernel event system — two channels with no ordering guarantee between them. With iTerm2’s bracketed-paste mode on, the \e[200~ paste prefix could reach readline before the DEL bytes, at which point readline treats the deletions as literal \x7f characters and your “replace” becomes an “append garbage.”

    The fix fell out of the sandbox work: cross-process AX needs Accessibility anyway, which we can’t rely on. So everything — backspaces and the ⌘V paste — now goes through CGEventPost, delivered through the same event stream in order. Sometimes the platform restriction is the code review.

    What I’d tell past me

    • If your Mac App Store app needs global hotkey observation: listen-only CGEventTap + Input Monitoring. Don’t waste a week on Accessibility.
    • Never trust CGPreflightListenEventAccess after launch; trust tapCreate.
    • Post all synthetic input through one channel and tag it.
    • Reuse your CGEventSource.
    • The system permission prompt can’t be dismissed programmatically — don’t stack your own dialog on top of it. One prompt, plus a quiet in-window banner for the people who dismissed it.

    ClipRing is free on the App Store and Mac App Store — clipboard history for Mac, iPhone, and iPad, synced through your own iCloud (the app’s privacy label is “Data Not Collected”, which for a clipboard tool I consider a feature). The ⇧⌘V terminal cycling is in the free tier. One-time purchase for the unlimited tier; no subscription, ever.

    — I am Arthur Li of Microdog Labs. Happy to answer anything about event taps, sandboxed StoreKit, or why iOS clipboard managers are the way they are.