Skip to content

fix: recover keyboard input when Chromium reports keyCode 229 with no IME active - #335

Open
drewvogg wants to merge 1 commit into
kernel:mainfrom
drewvogg:fix/keycode-229-windows-secondary-language
Open

fix: recover keyboard input when Chromium reports keyCode 229 with no IME active#335
drewvogg wants to merge 1 commit into
kernel:mainfrom
drewvogg:fix/keycode-229-windows-secondary-language

Conversation

@drewvogg

@drewvogg drewvogg commented Aug 14, 2026

Copy link
Copy Markdown

Checklist


Problem

On certain end-user machines the live view accepts mouse input but silently drops all keyboard input — including in the remote Chromium's own address bar, so it is not page-specific. Full detail in #334.

The keydown handler returns unconditionally on keyCode === 229:

if (keydownEvent.keyCode === 229)
    return;

That is correct for genuine IME composition, but some Windows + Chromium configurations report keyCode 229 for every keydown on a text field with no IME active (crbug 864911; same symptom downstream in react#14512, select2#2482). On those machines every keystroke is discarded.

The failure is total rather than partial because interpret_event() only interprets a log beginning with a KeydownEvent or KeyupEvent — there is no branch for a leading KeypressEvent, so the keypress after a dropped keydown is orphaned and never interpreted.

Change

Fall through instead of returning, skipping only genuine composition:

if (keydownEvent.keyCode === 229) {
    if (e.isComposing || keydownEvent.key === 'Process')
        return;
}

This recovers both observed shapes of the quirk, using machinery already in the file:

  • keyCode 229 with a usable key ("a", "Enter") — KeydownEvent already prefers key over keyCode when resolving a keysym (L268-269), so it resolves correctly.
  • keyCode 229 with key === "Unidentified" (the react#14512 shape) — the keydown resolves to a null keysym, and interpret_event() then takes the keysym from the following keypress (L1168-1172). This is layout-correct, since keypress charCode is the character actually typed rather than a physical key position.

Both checks are needed for composition: isComposing is the authoritative signal, but it is still false on the very first keydown that starts composition, which is where the "Process" sentinel applies. Note e.isComposing is read from the raw event because KeyEvent does not copy that property.

The change is marked KERNEL: in-line, matching the existing NEKO: convention for local deviations in this vendored file, so it is visible during future upstream syncs.

Verification

Driving the real module with synthetic events, before and after. The harness reproduces the bug on main and confirms the fix, including that genuine IME composition is still suppressed:

########## BEFORE (upstream main) ##########
PASS  baseline: normal keydown+keypress (unaffected machine)
FAIL  variant A: false 229, key="a" (+keypress)          expected [97]     got []
FAIL  variant B: false 229, key="Unidentified" (+keypress)  expected [97]  got []
FAIL  variant A: false 229, key="Enter" (no keypress)    expected [65293]  got []
PASS  IME: composition start, key="Process" -> must NOT type
PASS  IME: mid-composition, isComposing=true -> must NOT type

########## AFTER (this PR) ##########
PASS  baseline: normal keydown+keypress (unaffected machine)
PASS  variant A: false 229, key="a" (+keypress)
PASS  variant B: false 229, key="Unidentified" (+keypress)
PASS  variant A: false 229, key="Enter" (no keypress)
PASS  IME: composition start, key="Process" -> must NOT type
PASS  IME: mid-composition, isComposing=true -> must NOT type

I did not add this as a test file, since the client has no test runner configured (package.json has serve/build/lint only) and the file is vendored. Happy to contribute it under whatever layout you'd prefer if useful.

Reproduction harness (node test-229.mjs <path-to-guacamole-keyboard.js>)
// Node >=21 defines navigator as a getter-only global, so override it.
Object.defineProperty(globalThis, 'navigator', {
  value: { platform: 'Win32', userAgent: 'Chrome/Windows' },
  writable: true, configurable: true,
})
globalThis.window = {
  setTimeout: () => 0, clearTimeout: () => {},
  setInterval: () => 0, clearInterval: () => {},
}
globalThis.document = { createElement: () => ({ style: {} }) }

const Keyboard = (await import(process.argv[2])).default

function mkEvent(type, props) {
  return {
    type, keyCode: props.keyCode, key: props.key, location: 0,
    isComposing: props.isComposing || false,
    shiftKey: false, ctrlKey: false, altKey: false, metaKey: false,
    getModifierState: () => false,
    preventDefault() { this.defaultPrevented = true },
  }
}

function run(events) {
  const handlers = {}
  const el = { addEventListener: (t, fn) => { handlers[t] = fn } }
  const kbd = new Keyboard()
  const pressed = []
  kbd.onkeydown = (keysym) => { pressed.push(keysym); return true }
  kbd.onkeyup = () => {}
  kbd.listenTo(el)
  for (const e of events) handlers[e.type] && handlers[e.type](e)
  return pressed
}

const A = 0x61, ENTER = 0xff0d
const scenarios = [
  { name: 'baseline: normal keydown+keypress (unaffected machine)',
    events: [mkEvent('keydown', { keyCode: 65, key: 'a' }), mkEvent('keypress', { keyCode: 97 })],
    expect: [A] },
  { name: 'variant A: false 229, key="a" (+keypress)',
    events: [mkEvent('keydown', { keyCode: 229, key: 'a' }), mkEvent('keypress', { keyCode: 97 })],
    expect: [A] },
  { name: 'variant B: false 229, key="Unidentified" (+keypress)',
    events: [mkEvent('keydown', { keyCode: 229, key: 'Unidentified' }), mkEvent('keypress', { keyCode: 97 })],
    expect: [A] },
  { name: 'variant A: false 229, key="Enter" (no keypress)',
    events: [mkEvent('keydown', { keyCode: 229, key: 'Enter' }), mkEvent('keyup', { keyCode: 13, key: 'Enter' })],
    expect: [ENTER] },
  { name: 'IME: composition start, key="Process" -> must NOT type',
    events: [mkEvent('keydown', { keyCode: 229, key: 'Process' })], expect: [] },
  { name: 'IME: mid-composition, isComposing=true -> must NOT type',
    events: [mkEvent('keydown', { keyCode: 229, key: 'a', isComposing: true }),
             mkEvent('keypress', { keyCode: 97, isComposing: true })], expect: [] },
]

let failures = 0
for (const s of scenarios) {
  let got; try { got = run(s.events) } catch (err) { got = `THREW: ${err.message}` }
  const ok = JSON.stringify(got) === JSON.stringify(s.expect)
  if (!ok) failures++
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${s.name}\n        expected ${JSON.stringify(s.expect)}  got ${JSON.stringify(got)}`)
}
console.log(`\n${failures === 0 ? 'ALL PASS' : failures + ' FAILED'}`)

What I could not verify

Stating this plainly so you can weigh it: we were not able to capture keydown events from an affected machine. The users are at a customer site where we could not get devtools output. So the keyCode 229 diagnosis is inferred — from the symptom set (mouse works, focus works, the remote address bar is equally unaffected by typing, and the same session and live view URL work fine for other users on other machines) plus reading this file, where the 229 guard is the only silent, keyboard-only, machine-configuration-dependent early return in the keydown path.

If you would like that confirmed before merging, the check on an affected machine is:

addEventListener('keydown', e => console.log(e.key, e.keyCode, e.isComposing), true)

keyCode === 229 on ordinary keys confirms it. I'm glad to carry that back to the customer if you'd rather have the evidence first, and equally happy to adjust the approach if you'd prefer to solve it elsewhere in the stack.

Reported originally as a live view issue by a Kernel customer of ours; the end-user workaround in the meantime is to remove secondary languages under Windows Settings → Time & Language → Language, then restart the browser.

🤖 Generated with Claude Code


Note

Low Risk
Single, localized change to vendored keyboard event filtering with explicit IME safeguards; affects client input path only, not auth or data handling.

Overview
Fixes total loss of keyboard input in the live view Guacamole client on some Windows + Chromium setups where every keydown reports keyCode 229 even with no IME active.

The vendored guacamole-keyboard.js keydown handler no longer drops all 229 events. It still ignores real composition (isComposing or key === 'Process') but logs and interprets false 229 keydowns, using existing key / follow-up keypress resolution so remote typing works again.

Reviewed by Cursor Bugbot for commit 958fc1f. Bugbot is set up for automated code reviews on this repo. Configure here.

…eyCode 229

Some Windows + Chromium configurations report keyCode 229 for every keydown
on a text field, even when no IME is active. The unconditional early return
in the keydown handler then drops every keystroke, while mouse input keeps
working -- users can click in the live view but cannot type anywhere,
including the browser's own address bar.

interpret_event() only interprets an event log that begins with a keydown or
a keyup, so the keypress following a dropped keydown is orphaned and never
interpreted. That is why no key works rather than only some.

Fall through instead of returning, and skip only genuine composition
(isComposing, or the "Process" sentinel for the composition-starting keydown,
where isComposing is still false). This recovers both observed shapes of the
quirk: keyCode 229 with a usable key string resolves via the existing
key-over-keyCode preference in KeydownEvent, and keyCode 229 with key
"Unidentified" resolves from the following keypress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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