Skip to content

grabber: scope (0,0) built-in seize match to internal-bus transports#47

Merged
jackielii merged 1 commit into
jackielii:mainfrom
ingara:fix/grabber-seize-builtin-transport
Jun 7, 2026
Merged

grabber: scope (0,0) built-in seize match to internal-bus transports#47
jackielii merged 1 commit into
jackielii:mainfrom
ingara:fix/grabber-seize-builtin-transport

Conversation

@ingara

@ingara ingara commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

The bug

The (0,0) FIFO built-in alias I added in #45 matches on keyboard usage alone, so the seize grabs every connected keyboard (external USB/Bluetooth ones, plus the Karabiner VHIDD), not just the internal one. The start() un-seize pass tried to release the extras, but it closed any non-zero-VendorID device (so it also broke an explicitly-aliased external keyboard), and a keyboard that re-enumerated afterwards (replug, wake) was never re-filtered, so it stayed seized and its keys picked up the forwarded [device builtin] remaps.

What surfaced this: a backslash/backspace swap scoped to [device builtin] leaking onto an external keyboard, intermittently.

The fix

Scope the (0,0) match by Transport instead of usage alone: Transport ∈ {FIFO, SPI} + keyboard usage, so only the internal-bus keyboard matches. External USB/Bluetooth keyboards never enter the seize, at initial open or on later re-enumeration.

I tried Built-In=1 first, since it's the obvious scope and what hidutil --matching uses. But IOHIDManager device-matching silently ignores the Built-In key. Verified on Apple Silicon / Tahoe with both CFNumber 1 and CFBoolean true: the external still matched. Transport is honored, so the match uses that.

With the match scoped this way it's exact, so the old per-start() un-seize pass is gone: external keyboards report USB/Bluetooth, and the Karabiner VHIDD I inject into exposes no Transport property at all (checked: hidutil property --matching '{"VendorID":0x16c0}' --get Transport returns (null)), so none of them can match the transport-scoped dict. The only matches are the internal keyboard and any explicit (vendor,product) alias. Dropping the pass also fixes the old bug where it closed an explicitly-aliased external whenever a (0,0) alias coexisted.

Verification

Two tests: a unit test for the match predicate, and a SKHD_HID_LIVE=1-gated live test that builds the production match, opens the manager in observe mode (no seize, so no root), and asserts no non-built-in device is in the matched set. The live test is skipped in CI, since it needs real hardware and an external keyboard to mean anything.

On my machine (Apple Silicon, NEO ERGO USB external attached):

(0,0) match -> 1 built-in (VID-less), 0 external

Before this change the external was in the matched set.

What I haven't verified

Only FIFO is confirmed here. SPI is included defensively, but I couldn't test a machine that reports it. If a built-in reports some other transport its keyboard won't be matched, so its remaps silently stop. That's the same class of risk the (0,0) alias already carried, just narrower now.

This is verified at the match layer (observe mode): the change is to which devices the matching dict selects, plus removing the post-match pass. The seize mechanism itself (IOHIDManagerOpen with kIOHIDOptionsTypeSeizeDevice) is untouched. I didn't run the seize end-to-end on this branch because of an unrelated TCC/signing issue on my install.

DeviceCheck.isPresent (used to decide whether to forward rules to the grabber) still detects the built-in by a VID-less heuristic rather than transport — same effective result as the old seize match, so the two no longer use identical logic. I left it alone here since reconciling them properly means sharing one matcher across the agent and grabber modules; happy to do that as a follow-up.

If you'd rather go further, the per-device seize model Karabiner uses (open the manager without seize, then IOHIDDeviceOpen(SeizeDevice) per chosen device) would be the more thorough route, and would also make built-in detection a per-device property read instead of a transport guess. I can take that on if you want it. This PR is the smaller, contained fix to the matching I introduced in #45.

The (0,0) FIFO built-in alias matched on keyboard usage alone, which
captured every connected keyboard (external USB/Bluetooth + the Karabiner
VHIDD), not just the internal one. A one-shot un-seize pass in start()
released the extras, but it closed any non-zero-VendorID device (so it
also broke an explicitly-aliased external) and never re-ran for devices
that enumerated later (replug, wake), leaving an external seized and
remapped.

Built-In=1 looked like the obvious scope, but IOHIDManager
device-matching silently ignores the Built-In key (verified on Apple
Silicon / Tahoe with both CFNumber 1 and CFBoolean true; the external
still matched). Transport is honored, so match the built-in on its
internal-bus transport (FIFO, plus SPI defensively).

That makes the match exact, so the un-seize pass is gone: external
keyboards report USB/Bluetooth, and the Karabiner VHIDD exposes no
Transport property at all (hidutil reports it as null), so neither can
match the transport-scoped dict. The only devices left that match are the
internal keyboard and any explicit (vendor,product) alias.

Adds a unit test for the match predicate and a SKHD_HID_LIVE-gated live
test that asserts the production match captures no non-built-in device on
real hardware (skipped in CI).
@jackielii

Copy link
Copy Markdown
Owner

thanks

@jackielii jackielii merged commit 144a47d into jackielii:main Jun 7, 2026
2 checks passed
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.

2 participants