From e54a50a6dce85c51e66741fae486fa07ba35dfd4 Mon Sep 17 00:00:00 2001 From: Dan Share Date: Fri, 14 Nov 2025 12:06:41 +0000 Subject: [PATCH] Add fix for AltGr key --- .../DesktopSession/InputHandler.test.ts | 136 ++++++++++++++++++ .../DesktopSession/InputHandler.tsx | 12 ++ 2 files changed, 148 insertions(+) diff --git a/web/packages/shared/components/DesktopSession/InputHandler.test.ts b/web/packages/shared/components/DesktopSession/InputHandler.test.ts index 7345e2da1f056..7d5526f96a905 100644 --- a/web/packages/shared/components/DesktopSession/InputHandler.test.ts +++ b/web/packages/shared/components/DesktopSession/InputHandler.test.ts @@ -188,5 +188,141 @@ describe('InputHandler', () => { ); expect(shiftCalls).toHaveLength(0); }); + + it('Control and Alt are not synchronized when AltGr pressed', () => { + // First, set Control and Alt to DOWN to mimick the what happens when a + // user presses AltGraph in a browser. + const ctrlDownEvent = new KeyboardEvent('keydown', { + code: 'ControlLeft', + }); + inputHandler.handleInputEvent({ + e: ctrlDownEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + const altDownEvent = new KeyboardEvent('keydown', { code: 'AltRight' }); + inputHandler.handleInputEvent({ + e: altDownEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + // Clear these events so they're not counted below + mockTdpClient.sendKeyboardInput.mockClear(); + + const altGrEvent = new KeyboardEvent('keydown', { + code: 'KeyQ', + ctrlKey: false, + altKey: false, + }); + + // There isn't a key code for AltGraph, so instead mock the + // getModifierState to return true for AltGraph + Object.defineProperty(altGrEvent, 'getModifierState', { + value: (key: string) => key === 'AltGraph', + }); + + inputHandler.handleInputEvent({ + e: altGrEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + // Check that synchronizeModifierState doesn't try to set the remote state + // (down) to that of the local state (up) + const controlCalls = mockTdpClient.sendKeyboardInput.mock.calls.filter( + call => call[0].includes('Control') + ); + const altCalls = mockTdpClient.sendKeyboardInput.mock.calls.filter(call => + call[0].includes('Alt') + ); + + expect(controlCalls).toHaveLength(0); + expect(altCalls).toHaveLength(0); + }); + + it('Shift and Meta are still synchronized when AltGr is active', () => { + const shiftDownEvent = new KeyboardEvent('keydown', { + code: 'ShiftLeft', + }); + inputHandler.handleInputEvent({ + e: shiftDownEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + const metaDownEvent = new KeyboardEvent('keydown', { code: 'MetaLeft' }); + inputHandler.handleInputEvent({ + e: metaDownEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + mockTdpClient.sendKeyboardInput.mockClear(); + + // Press AltGr key with Shift and Meta released + const altGrEvent = new KeyboardEvent('keydown', { + code: 'KeyA', + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + }); + + Object.defineProperty(altGrEvent, 'getModifierState', { + value: (key: string) => key === 'AltGraph', + }); + + inputHandler.handleInputEvent({ + e: altGrEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + // Shift & Meta should still be synchronized to UP even with AltGr active + expect(mockTdpClient.sendKeyboardInput).toHaveBeenCalledWith( + 'ShiftLeft', + ButtonState.UP + ); + expect(mockTdpClient.sendKeyboardInput).toHaveBeenCalledWith( + 'MetaLeft', + ButtonState.UP + ); + }); + + it('handles AltGr + Shift combination correctly', () => { + const altGrShiftEvent = new KeyboardEvent('keydown', { + code: 'KeyA', + shiftKey: true, + ctrlKey: false, + altKey: false, + }); + + Object.defineProperty(altGrShiftEvent, 'getModifierState', { + value: (key: string) => key === 'AltGraph' || key === 'Shift', + }); + + inputHandler.handleInputEvent({ + e: altGrShiftEvent, + state: ButtonState.DOWN, + cli: mockTdpClient, + }); + + // Shift should be synchronized, but not Control/Alt + const shiftCalls = mockTdpClient.sendKeyboardInput.mock.calls.filter( + call => call[0].includes('Shift') + ); + const controlCalls = mockTdpClient.sendKeyboardInput.mock.calls.filter( + call => call[0].includes('Control') + ); + const altCalls = mockTdpClient.sendKeyboardInput.mock.calls.filter(call => + call[0].includes('Alt') + ); + + expect(shiftCalls.length).toBeGreaterThan(0); + expect(controlCalls).toHaveLength(0); + expect(altCalls).toHaveLength(0); + }); }); }); diff --git a/web/packages/shared/components/DesktopSession/InputHandler.tsx b/web/packages/shared/components/DesktopSession/InputHandler.tsx index 7f83cb5df7e70..8d0ccc9aa6a1a 100644 --- a/web/packages/shared/components/DesktopSession/InputHandler.tsx +++ b/web/packages/shared/components/DesktopSession/InputHandler.tsx @@ -191,7 +191,18 @@ export class InputHandler { return; } + // Check if AltGraph is being pressed + const isAltGraphActive = e.getModifierState('AltGraph'); + this.remoteModifierState.forEach((state, modifier) => { + // When AltGr is pressed, browsers synthesize ControlLeft+AltRight, but + // getModifierState('Control') and getModifierState('Alt') return false + // because the physical keys aren't pressed. Skip sync to avoid sending + // incorrect UP events while AltGr is active. + if (isAltGraphActive && (modifier === 'Control' || modifier === 'Alt')) { + return; + } + const localState = e.getModifierState(modifier) ? ButtonState.DOWN : ButtonState.UP; @@ -200,6 +211,7 @@ export class InputHandler { // If the local state is different from the remote state, send the updates. cli.sendKeyboardInput(modifier + 'Left', localState); cli.sendKeyboardInput(modifier + 'Right', localState); + // Update the remote state to match the local state. this.remoteModifierState.set(modifier, localState); }