Passing the Mac Caps Lock input switch to a QEMU VM
Published · Updated (KST)
On a Mac with a Korean input source, you can turn on "Use the Caps Lock key to switch to and from ABC". Caps Lock then becomes the Korean/English key. A short press switches input sources. A long press engages Caps Lock. We use it all day, so we expected it to work inside a VM too.
It didn't. We were running Omarchy on a Mac through Try Omarchy, a launcher that wraps QEMU in a Mac app. A short press did nothing. A long press sometimes changed something, but not consistently. The question came down to two things: how macOS reports Caps Lock, and how QEMU receives it.
We patched and re-tested with the physical key more than ten times over October 20 and 21. Everything here was measured on one machine: Apple Silicon, macOS 26.4, Korean 2-Set and ABC input sources. We found no Apple documentation, public source or search result that describes this behavior, so we measured it. If you build an app that intercepts keyboard input on macOS (a VM, a remote desktop client, a key remapper), these notes may save you some time.
Scope. The fix and the measurements cover only the Korean 2-Set + ABC combination. macOS offers the same Caps Lock option for other non-Latin input sources (Apple's documentation uses Chinese as its example), so they may show the same symptoms. We have not tested them. Follow-up: try-omarchy#403.
What the Caps Lock switch looks like
The setting lives in System Settings › Keyboard › Text Input › Input Sources › Edit. It only appears after you add a non-Latin input source. Apple's support page describes it in two lines:
- Turn it on to switch quickly between a non-Latin input source and a Latin input source.
- To type in all caps, press and hold the key until the Caps Lock indicator appears.
The page doesn't say how long a long press is. Apple's open-source IOHIDKeyboardFilter.mm defines kCapsLockDelayMS 75, but that is the delay before the lock engages. We found no code in the public sources that decides when to switch input sources.
So we recorded what macOS actually sends. We attached a listen-only CGEventTap at the HID level and logged the TIS (input source) state and the IOKit Caps Lock state with each event.
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue)
let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap,
options: .listenOnly, eventsOfInterest: CGEventMask(mask),
callback: { _, type, e, _ in
let kc = e.getIntegerValueField(.keyboardEventKeycode)
print(type.rawValue, kc, e.flags.contains(.maskAlphaShift), ioCapsLock(), tisIsKorean())
return Unmanaged.passUnretained(e)
}, userInfo: nil)
ioCapsLock() opens the kIOHIDSystemClass service with IOServiceOpen and calls IOHIDGetModifierLockState(conn, kIOHIDCapsLockState, &state). This is the same state as the green light on the key. We also polled it every 100 ms to catch changes between events.
What we measured
Recorded with Notes in front (ms since the recorder started):
# short press (Korean → ABC)
144411 type=12 kc=57 caps=1 io=1 tis=1 ← lock tentatively on at key down
144426 type=12 kc=255 caps=0 io=0 tis=1 ← keyCode 0xff, lock reverted
145622 ... tis=0 ← next record shows ABC
# long press (from ABC)
149992 type=12 kc=57 caps=1 io=1 tis=0
150008 type=12 kc=255 caps=0 io=0 tis=0 ← +16 ms
150494 type=12 kc=255 caps=1 io=1 tis=0 ← +500 ms, the real lock
150507 LOCK io=1 ← green light stays on
| Action | What macOS sends | Result |
|---|---|---|
| Short press | kc57 caps=1 → keyCode 0xff caps=0 | Input source switches, no lock |
| Long press | kc57 → 0xff caps=0 → about 500 ms later 0xff caps=1 | Locks. From Korean, also switches to ABC |
| Unlock | kc57 caps=0, among others | Unlocks. Returning to Korean sometimes happens with no 0xff |
Three things stood out.
- kc57 alone doesn't tell you a switch happened. kc57 arrives at key down, and keyCode 0xff follows right after. We found no mention of 0xff in Apple's documentation or developer forums. The only public reference we found was a comment in Firefox's
TextInputHandler.mm: since 10.9, unsupported keys arrive as FlagsChanged with keyCode 0xFF (in the context of Japanese conversion keys). - The lock turns on tentatively the moment you press. On a short press it is reverted. If you act on the state at key down, Caps Lock flickers every time.
- Locking switches the input source to ABC. That's why a long press in Korean gives you uppercase Latin letters.
Problem 1: QEMU silently swallows 0xff
QEMU's Cocoa display (ui/cocoa.m) doesn't forward the physical Caps Lock press. It compares the event's Caps flag with the guest's Caps state and taps Caps Lock only when they differ. flagsChanged events go through a switch on the key code, and that switch has no default. 0xff matches no case, the event is marked as handled, and nothing reaches the guest.
That was the first symptom: a short press did nothing.
Our first patch sent a Hangul key when a flagsChanged event left the Caps flag unchanged. To know when the press ended, we read the physical key state with CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, kVK_CapsLock). It passed in simulation.
let e = CGEvent(keyboardEventSource: nil, virtualKey: 57, keyDown: true)!
e.type = .flagsChanged
e.flags = []
e.postToPid(qemuPid)
It failed with the real key, for two reasons:
- A real short press ends in 0xff. The simulation sent 57, which is why it passed.
CGEventSourceKeyState(HID, 57)often stayed at 1 after the key was released. We could never tell that the press had ended.
When we changed the simulation to send 0xff, nothing happened at all. A keyCode 255 flagsChanged injected with CGEventPostToPid never reached the test app's sendEvent:. A small AppKit repro confirmed it: 57 and 56 arrived, 255 didn't. A real 0xff reaches QEMU through its HID event tap, which is a different path.
Lesson: synthetic events can't prove this works. You have to press the real key and read logs on both the host and the guest.
Problem 2: seeing what the guest actually received
Host logs don't show what QEMU sent to the guest. QEMU's input trace events do. Enable them over QMP and they print to stderr.
# enable the trace over QMP
{"execute":"trace-event-set-state","arguments":{"name":"input_event_key_qcode","enable":true}}
input_event_key_qcode con 0, key qcode caps_lock, down 1
input_event_key_qcode con 0, key qcode caps_lock, down 0
input_event_key_qcode con 0, key qcode caps_lock, down 1
With a build that mapped 0xff to Hangul, every short press sent only caps_lock. Forty pairs went out and not a single lang1 (Hangul). Our guess at the time: the Caps sync block ran before the Hangul check, and QEMU saw a different Caps flag than the event tap did. If so, the "flag unchanged" condition could never be true. That guess also fit the original report that short presses failed while long presses sometimes worked.
The guest (fcitx5 on Omarchy) was fine. Sending lang1 over QMP switched Korean and English back and forth correctly. fcitx5 includes Hangul in its default TriggerKeys, so the guest needed no configuration.
Problem 3: which Caps state to trust
To keep the guest in sync, QEMU needs to know whether the Mac is in Caps Lock right now. We tried each candidate:
| Source | Result |
|---|---|
The event's modifierFlags |
Reads 1 at key down, so it flickers every press and typed letters come out uppercase |
+[NSEvent modifierFlags] (global) |
Misses the lock when QEMU's tap swallows events (in one run it changed only twice in the whole log) |
CGEventSourceFlagsState(HID) AlphaShift |
Almost always 0 |
IOHIDGetModifierLockState |
Correct, except for the tentative 1 at key down |
The IOKit lock state was the right source. The tentative value during the press was the problem. In one run, the next key arrived 85 ms after the Caps Lock press, while the lock still read a tentative 1. So we added a pending state: after a Caps Lock press, don't sync Caps until macOS has decided.
Problem 4: with the VM in front, macOS can't lock Caps
With short presses and flicker fixed, one bug remained. A long press didn't lock Caps from ABC. It only worked from Korean.
We recorded the same action with Notes in front and with the VM in front:
# Notes, ABC, long press
kc57 io=1 → +16ms 0xff io=0 → +500ms 0xff caps=1 io=1 → LOCK io=1 stays
# VM in front, ABC, long press (same all 6 times)
kc57 io=1 → (no 0xff at +16ms) → +500ms 0xff caps=0 io=0 → LOCK io=0
With the VM in front, macOS itself reverted the lock. QEMU was swallowing the events in two places:
- The full-grab HID event tap,
handleTapEvent, drops handled events withreturn NULL. QemuApplication -sendEvent:skips[super sendEvent:]whenhandleEvent:returns true, and for flagsChanged it always does.
macOS appears to need the frontmost app to pass the event on to AppKit before it can finish deciding. We suspect it worked from Korean because the Korean input method runs as a separate process and handles the lock itself.
Letting the event through the tap wasn't enough. Once sendEvent: also passed it to super, the VM produced the same sequence as Notes. We also fixed a duplicate: both paths had been handling the same key. Now, when the tap is active and the keyboard is grabbed, only the tap handles the key and the app path just hands it to macOS.
Problem 5: the mirror goes backwards
Our first approach was a mirror. QEMU tracked its own guess of the guest's Korean/English state and sent a Hangul (lang1) toggle whenever the Mac's input source disagreed. Korean on the Mac should mean Korean in the guest, and ABC should mean English. After a long-press lock, macOS is in ABC, so the guest should be in English with Caps on.
Instead, the guest switched to Korean the moment the green light came on. Two problems overlapped:
- Initial value. We initialized the guess from the Mac's input source at the first event. But in this setup fcitx always boots in English. If the Mac was in Korean at that moment, the mirror stayed inverted for good. We changed the guess to start as English at QEMU startup and on guest reset, using
qemu_register_reset. - Read timing. Reading TIS while handling a key event sometimes ran before or after macOS actually switched. One press could send two toggles and leave the guest opposite the Mac.
So at that stage we moved the decision out of key handling and onto the kTISNotifySelectedKeyboardInputSourceChanged notification. Each notification was compared against the guess, and a Hangul toggle went out if they differed. After switching sources in another app, the first key back in the VM triggered one sync. We didn't sync the instant the window became active, because right after launch the guest is still booting and fcitx isn't running yet. A toggle sent then is lost while the guess flips, and the mirror inverts again. A toggle sent from a TIS notification during boot, though, could still be lost.
For a while this looked correct. It wasn't, and Problem 7 explains why.
Problem 6: don't guess the guest's Caps state
With Korean/English working, uppercase went wrong. The unpatched QEMU tracked the guest's Caps state by counting the Caps Lock presses the Cocoa layer had sent. While debugging, we sent caps_lock directly over QMP, and those presses weren't counted. From then on the count was inverted, and a Caps Lock press meant to turn Caps on turned it off.
Injecting keys into the guest showed it immediately:
# Mac LED off, QEMU believes guest Caps=0
'e' → E ← the guest was already on
caps_lock → 'e' → e
caps_lock → 'e' → E
The fix was to stop guessing. With virtio-input, the guest reports its keyboard LEDs (EV_LED Caps Lock) back to QEMU, and QEMU can read them with qemu_input_get_leds_mask(). We switched to an absolute comparison: if the guest LED differs from the Mac's lock state, send Caps Lock. A mismatch gets corrected on the next key. After sending Caps Lock, QEMU uses its expected value until the LED report arrives, for up to 300 ms, and then goes back to the reported LED. A report later than that could still cause a duplicate toggle. In our tests the guest reported every LED change and no duplicate toggles occurred.
One issue was left: the first letter right after locking. We had been holding a Caps press as "undecided" for one second. But macOS confirms the lock at about 500 ms with 0xff caps=1 io=1. A letter typed at 983 ms got caught in the one-second wait and came out lowercase. Now QEMU decides the moment that 0xff arrives.
Problem 7: the box was ㅁ
We thought we were done. We rebuilt without debug logging and re-ran the full checklist. Only the short press passed. Where Caps Lock should have produced AAAA, the terminal showed "□□□□". After visiting another app, or releasing the mouse with ⌃⌥G and coming back, Korean/English was the opposite of the Mac.
The boxes weren't broken glyphs. They were ㅁ, the Hangul letter, drawn in the terminal font. The guest was in Korean, so a came out as ㅁ. An earlier run that we had marked as passing, reasoning that "Hangul ㅁ looks like a box", was the same misreading.
Don't judge characters by how they look on screen. The bytes differ:
$ printf 'ㅁ' | xxd
00000000: e385 81 ← U+3141 Hangul letter ㅁ
$ printf '□' | xxd
00000000: e296 a1 ← U+25A1 white square
We booted a debug build again to find the cause. Right after boot the log showed tis-notify old=0 new=1 active=1 sent=1. The lang1 went out before fcitx started and was lost. The guess flipped to Korean while the guest stayed in English. It was inverted from the start: with the Mac in ABC, typing abc produced 뮻.
The mirror failed for three reasons:
- Lost at boot. A key sent before the guest input method is ready disappears, and QEMU has no way to know when fcitx is ready.
- Per-window state. fcitx keeps input state per window. A new window starts in English, and an existing window keeps its old state, so either can drift from the Mac.
- Toggles inside the guest. Left Shift on its own (
AltTriggerKeys=Shift_L) and Super+I both switch the guest.
A toggle is relative. Miss one and every switch after it is inverted. The initial value and timing fixes from Problem 5 were patches on a broken design.
The fix: separate keys for "to Korean" and "to English"
We dropped the toggle and used absolute keys: Henkan means "to Korean" and Muhenkan means "to English". The fcitx config in our test guest already had ActivateKeys=Henkan and DeactivateKeys=Muhenkan, as Omarchy's current default does. XKB adds inet(evdev) to every layout, which maps both keys. So the patch changes no guest files. Profiles created before October 2026 or edited by hand may not have these keys, so check them first. We removed the guessed state and the Hangul toggle. The Caps side (pending decision, guest LED comparison, event pass-through) stayed as it was.
QEMU sends the key at three points:
- On a TIS change notification, only while the VM window is in front.
- On the first key after the VM window becomes active. This also catches changes made in other apps.
- Before Space, Return, Tab, Esc and the arrow keys, at most once every 2 seconds.
The third point covers what gets lost. A key dropped during boot, a new window that starts in English, a switch made inside the guest with Shift or Super+I: each gets pulled back to the Mac's state at the next resend. If none of those keys is pressed, nothing is resent, so there is no guarantee of a fix within a fixed time. At first we resent every 2 seconds before any key while typing. But a duplicate Henkan reaching the Hangul engine can commit and split the syllable being composed. So we stopped resending before letter keys and kept only the keys listed above.
Known limits:
- It only works when the guest fcitx has ActivateKeys set to Henkan and DeactivateKeys set to Muhenkan. With other settings the guest won't follow the Mac. It won't invert, either.
- The Mac's input source is the reference. A switch made only in the guest is undone at the next resend.
- In windows fcitx doesn't handle (no text input), the two keys go to the app. We haven't checked every app.
- The patch relies on QEMU's full-grab HID event tap, which needs macOS Accessibility permission. Without it the tap isn't created, 0xff never reaches QEMU, and Caps Lock behaves as before.
Results
We tested the final build, with debug output, using the physical keyboard and a freshly booted VM. All manual keyboard scenarios passed, and so did the full E2E suite.
| On the Mac (VM in front) | Result |
|---|---|
| Short press, from ABC and from Korean | Pass |
| Typing after a long-press lock | Pass |
| Typing after unlocking | Pass |
| Switching input source in another app, then returning | Pass |
| Releasing the mouse with ⌃⌥G, then returning | Pass |
make e2e |
100 passed, 0 failed |
The QEMU trace showed henkan 22, muhenkan 7 and caps_lock 2. The toggle key lang1 appeared 0 times.
The change is in try-omarchy#404.
Without an indicator, even correct behavior is confusing
During testing, the top bar made judging harder. On a test guest without the label fix, English showed as "KO", Korean as "한", and Caps Lock had no indicator at all. The screen alone didn't tell you which state you were in. In Korean mode a types ㅁ, which looks like a box in the terminal font. That's where the Problem 7 misreading came from.
| State | Test guest's top bar | Proposed |
|---|---|---|
| English, lowercase | KO | EN |
| English, Caps Lock | KO | EN in an accent-colored badge |
| Korean | 한 | 한 |
"KO" is xkb's name for the Korean layout (kr(kr104)). That layout types Latin letters, so when it sits next to the Hangul engine, "EN" is the accurate label. For the Caps Lock indicator, we proposed to Omarchy that the top bar widget use the capsLock value from hyprctl -j devices, which it already reads. Hyprland doesn't emit an event when Caps Lock changes, so the proposal uses a Caps_Lock binding that doesn't consume the key to tell the widget to re-read.
How other VMs handle it
| Tool | Approach |
|---|---|
| UTM (QEMU-based) | Option "Caps Lock (⇪) is treated as a key", off by default. By default it syncs only the lock state and doesn't send the key |
| Parallels | Parallels Tools syncs a Windows guest's input language with the Mac (KB 125490) |
| VMware Fusion | KB 303391: if Caps Lock gets out of sync, use Virtual Machine › Send Key › Caps Lock |
We found no VM that turns a short press into the guest's Korean/English key.
If you intercept keyboard input on macOS
- Don't ignore keyCode 0xff flagsChanged. In this setup, a short press ends with it.
- Don't swallow Caps events. Returning
NULLfrom the tap or skipping super insendEvent:broke the long-press lock from ABC. - Read the lock state from IOKit, but ignore the tentative value during a press. Treat the lock as confirmed when a 0xff arrives with both the Caps flag and the IOKit lock state at 1. Until then, decide on the next key.
- Sync the input source from the TIS change notification. Reading it during key handling gave inconsistent results.
- Don't guess the guest's state. For Caps, read the guest's LED. For Korean/English, this implementation has no way to query the guest, so it keeps resending absolute keys.
- Use absolute keys, not toggles. One lost toggle inverts everything after it. fcitx has separate "on" (Henkan) and "off" (Muhenkan) keys.
- Check characters by their bytes. A □ in the terminal may be ㅁ.
xxdtells them apart. - Don't trust synthetic events to pass a test. Injected 0xff never reached the app, and real keys take a different path.
- Don't send Caps Lock or the Korean/English key over QMP during a test. It changes the state under test. Before the LED comparison, QMP Caps Lock presses weren't counted by QEMU's guess and skewed every result after them.
Test harness notes
| Purpose | Method |
|---|---|
| What macOS sends | Listen-only HID tap + TIS state + IOHIDGetModifierLockState polled every 100 ms |
| What the guest got | QMP trace-event-set-state input_event_key_qcode |
| QEMU's internal decisions | A debug environment variable that logs type, keyCode, three flag sources, lock, TIS and handling path per event |
| Control | Same action once in Notes, once in the VM |
| Swapping the binary | Replacing QEMU inside the app bundle breaks the bundle signature. Re-sign with codesign --force --sign - --preserve-metadata=entitlements,requirements,flags,runtime and check with codesign --verify --deep --strict |
In this setup, a VM in a full-screen Space gave black images from both screencapture -l and QMP screendump. We ran the test VM in a window.
For every new build we shut the VM down completely and booted fresh. If fcitx or Caps state carries over from the previous run, you can't tell the new build's behavior from leftovers. Keep the recorder running well past the test, too. Once a decisive test ran just after the recorder stopped, and all we had from that run was a verbal report.
Takeaways
The patch went through more than ten revisions. Several that passed replay tests still failed with the real key. Most failures came from assuming how macOS would report things. Each added log removed one assumption.
Don't sync remote state with relative toggles. Look for an absolute control first. And when Apple doesn't document a behavior, measure it.
Data as of 2026-10-21
Sources
FAQ
How long is a "long press"?
We could not find the threshold in Apple's documentation or open source. kCapsLockDelayMS 75 in IOHIDKeyboardFilter is the delay before the lock engages, not the switch threshold. Don't copy the timing. Instead, react to the input source change notification and the 0xff event that confirms the lock.
Why not send the guest a Hangul toggle key?
A toggle is relative. Lose one and every switch after it is inverted. In testing, a toggle sent right after boot was lost before the guest input method started, and the guest stayed inverted from then on.
Does the guest need any configuration?
No changes were needed in our test guest. Its fcitx5 config already had ActivateKeys=Henkan and DeactivateKeys=Muhenkan. Profiles created before October 2026 or edited by hand may differ, so check those two settings.