macoc/docs/superpowers/specs/2026-10-25-caps-f19-remap.md

73 lines
3.7 KiB
Markdown

> Historical design. Superseded by 2026-10-25-in-process-hid-remap.md.
> The executable no longer starts hidutil or requests permission for it.
# Capswitch HID-Level Caps Lock Remapping
## Problem
macOS filters and toggles physical Caps Lock in the HID / WindowServer path.
A Quartz flagsChanged event does not reliably represent physical key-down and
key-up, especially for very short presses interleaved with normal typing.
Fixing CGEventTap callback timing alone cannot recover events already discarded.
## Chosen approach
- Use Apple's hidutil UserKeyMapping to translate HID Caps Lock usage
0x700000039 into HID F19 usage 0x70000006E.
- Handle ordinary virtual F19 (keycode 80) keyDown and keyUp through a
session-level CGEventTap, suppressing both.
- Retain Option modifier tracking through flagsChanged and the pure state
machine. Switching occurs synchronously on F19 release, never on F19 down.
- Keep TIS source enumeration outside the event tap callback and periodically
refresh the list. The TISSelectInputSource call remains synchronous.
- Use a small IOKit C shim from SwiftPM to set the system Caps Lock lock state
for the Option combination.
- Expose F19 down/up and input-switch counts in the status bar for real-Mac
acceptance testing without logging typed characters.
## Lifecycle and safety
- Create and enable the event tap before applying the global HID remap.
- Read and validate all existing mappings first; refuse Caps or F19 conflicts.
- Merge with any unrelated mappings and verify the result after writing.
- On Pause or Quit, disable interception and remove only Capswitch's exact pair.
- Persist an ownership marker for crash recovery; do not remove pre-existing
Caps -> F19 mappings not owned by Capswitch.
- Reconcile HID state on wake and periodically, since keyboard disconnects or
reboot can reset UserKeyMapping.
- Run only one Capswitch instance for the same bundle ID.
- Refuse app bundle replacement while a Capswitch process is running.
- Treat nonstandard/unparseable hidutil output as a hard error, not permission
to erase foreign mappings.
## Tests
XCTest covers rapid F19 presses mixed with normal keys, key repeat suppression,
Caps+Option in either order, pending Option release, router reset/orphan release,
hidutil OpenStep mapping output, and foreign mapping preservation in JSON.
The real macOS acceptance suite must verify:
- Clean Command Line Tools compilation with no Xcode project.
- Short Caps taps during continuous typing are observed as F19 down/up.
- On Caps release, the next typed letter uses the next configured layout.
- No stock input-source animation or HUD is triggered.
- Option+Caps toggles real Caps Lock on and off including the LED.
- Pause and Quit restore only the managed mapping.
- USB/Bluetooth devices and wake/reconnect recovery work.
- All Accessibility/TCC and old-process conditions are surfaced to the user.
Current Linux host cannot run AppKit or HID hardware tests.
## Diagnostic follow-up: no F19 despite stock Caps working
- The user's macOS native Caps input-source switch is currently enabled.
Do not silently modify it; surface a menu warning with a link to Keyboard.
- Global keyboard taps may need Input Monitoring. Active filtering taps can
work with Accessibility alone, so do not block before trying tapCreate.
Diagnose the Input Monitoring grant separately and request it if denied.
- CGEvent.tapIsEnabled and CFMachPortIsValid provide a basic tap health check.
- A global UserKeyMapping can exist while keyboard-service mappings are empty.
Surface the distinction through task doctor, and allow safe user-triggered
reapplication without dropping unrelated mappings.
- task doctor does not record or print typed text.