Skip to content

Replace the CGEventTap with a registered global hot key - #8

Merged
andyhtran merged 3 commits into
mainfrom
ft-carbon
Jul 26, 2026
Merged

Replace the CGEventTap with a registered global hot key#8
andyhtran merged 3 commits into
mainfrom
ft-carbon

Conversation

@andyhtran

Copy link
Copy Markdown
Owner

The event tap sat in the path of every keystroke on the system to act on a handful of them, and needed a watchdog to survive the OS disabling it — something it could never reliably do, since a tap starved by Secure Input looks identical to one that is merely idle.

RegisterEventHotKey takes CopyCat out of the input path entirely, but it is all-or-nothing: once a chord is claimed the OS swallows it before any app sees it, with no way to decline and let the keystroke through. The chord in question is plain ⌘V, so holding it unconditionally would break paste system-wide.

The arming gate

The chord is registered only across the window where CopyCat would have acted anyway — a target app frontmost, an image on the clipboard, Accessibility granted — which reproduces the old conditional behavior. Every gate input is observable outside the keystroke path (workspace activation, pasteboard change count, TCC trust), so a hung handler can only delay CopyCat’s own paste, never the user’s typing.

The gate is sampled up to one poll interval before a keystroke lands, so a chord can fire after it closed. That case hands the keystroke back rather than dropping it, releasing every armed chord first: the re-posted event carries the same modifiers the hot key matches on, so posting it while the chord is still claimed would feed it straight back in.

Also in here

  • Menu header tells apart "off", "needs Accessibility", and a chord another app owns — none of those are fixed the same way.
  • Local paste and the Secure Input HID sensor share one cooldown, so a single keystroke seen by both paths types the path once.
  • Package moves to Swift 6.2 for isolated deinit in hot key teardown; CI moves to a runner image that carries a 6.2 toolchain and now runs swift test.

Test plan

  • swift build and swift test clean locally on Swift 6.2 (150 tests).
  • New tests cover the arming gate across every input state, the passthrough path (chord released before the keystroke is re-posted, siblings released too, nothing posted when release fails), registration failure handling, and the shared cooldown.
  • Registration is exercised through a fake — claiming ⌘V for real would swallow it for the whole login session, including the test runner’s host.
  • Manual: with a screenshot on the clipboard, ⌘V in a target terminal types the path; ⌘V in a non-target app is untouched; revoking Accessibility disarms the chord and the menu says why; re-granting re-arms it without a relaunch.

🤖 Generated with Claude Code

andyhtran and others added 3 commits July 25, 2026 22:43
The tap sat in the path of every keystroke on the system to act on a
handful of them, and needed a watchdog to survive the OS disabling it —
which it could never actually do, since a tap starved by Secure Input
looks identical to one that is merely idle.

RegisterEventHotKey removes CopyCat from the input path entirely, but it
is all-or-nothing: once a chord is claimed the OS swallows it before any
app sees it, with no way to decline. The chord in question is plain ⌘V,
so it is armed only across the window where CopyCat would have acted
anyway — a target app frontmost, an image on the clipboard, Accessibility
granted — which reproduces the old conditional behavior. Every gate input
is observable outside the keystroke path, so a hung handler can only
delay CopyCat's own paste.

The gate is sampled up to a poll interval before a keystroke lands, so a
chord can fire after it closed. That case hands the keystroke back rather
than dropping it, releasing every armed chord first: the re-posted event
carries the same modifiers the hot key matches on, so posting it while
the chord is still claimed would feed it straight back in.

Also:
- Menu header now tells apart "off", "needs Accessibility", and a chord
  another app owns, since none of those are fixed the same way.
- Local paste and the Secure Input HID sensor share one cooldown, so a
  single keystroke seen by both paths types the path once.
- Requires Swift 6.2 for isolated deinit in hot key teardown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
macos-15 carries Xcode 16, whose Swift can't parse a 6.2 manifest, so the
build broke the moment the package moved to 6.2. Runner images ship one
Xcode major apiece, which makes the OS label the toolchain pin.

Also run swift test: the suite covers the rule that decides when ⌘V is
claimed system-wide, and a build-only job proves none of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v4 targets Node 20, which the runners now force onto Node 24 and warn
about. v5 is the release that made that move deliberate; v7 is the
current line and clears the same warning. Its one behavior change —
refusing to check out fork PRs under pull_request_target and
workflow_run — doesn't reach this workflow, which uses pull_request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@andyhtran
andyhtran merged commit 6a795ce into main Jul 26, 2026
1 check passed
@andyhtran
andyhtran deleted the ft-carbon branch July 26, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant