diff --git a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx index 3b36e777b..8dd3fed61 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx @@ -345,9 +345,9 @@ export const GuacamoleDisplay = forwardRef< (hasKeyboardFocusRef.current || displayIsFocused); if (!shouldCaptureInput) { + keyboard.reset(); keyboard.onkeydown = null; keyboard.onkeyup = null; - keyboard.reset(); return; } @@ -366,17 +366,8 @@ export const GuacamoleDisplay = forwardRef< }; keyboard.onkeyup = (keysym: number) => { - if (!clientRef.current) return; - if (!isVisible || !windowFocusedRef.current) return; - - const activeDisplay = displayElementRef.current; - const stillFocused = - !!activeDisplay && - typeof document !== "undefined" && - document.activeElement === activeDisplay; - - if (!hasKeyboardFocusRef.current && !stillFocused) return; - clientRef.current.sendKeyEvent(0, keysym); + // Focus loss must still release keys already sent to the remote host. + clientRef.current?.sendKeyEvent(0, keysym); }; }, [isVisible]); diff --git a/plugins/remote-desktop/tests/frontend/guacamole-key-release.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-key-release.test.ts new file mode 100644 index 000000000..c735cedab --- /dev/null +++ b/plugins/remote-desktop/tests/frontend/guacamole-key-release.test.ts @@ -0,0 +1,105 @@ +import ts from "typescript"; +import Guacamole from "guacamole-common-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import displaySource from "../../src/frontend/GuacamoleDisplay.tsx?raw"; + +// Exercise the display's actual focus callback with Guacamole's real keyboard. +const source = ts.createSourceFile( + "GuacamoleDisplay.tsx", + displaySource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +let callback: string | undefined; +function visit(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(source) === "refreshKeyboardHandlers" && + node.initializer && + ts.isCallExpression(node.initializer) + ) { + callback = node.initializer.arguments[0].getText(source); + } + ts.forEachChild(node, visit); +} +visit(source); +if (!callback) throw new Error("Missing refreshKeyboardHandlers callback"); +const runRefresh = new Function( + "state", + ts.transpileModule( + `const { keyboardRef, clientRef, displayElementRef, isVisible, windowFocusedRef, hasKeyboardFocusRef } = state; + (${callback})();`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText, +) as (state: Record) => void; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe("remote keyboard focus loss", () => { + it.each(["window", "display", "hidden", "tab"])( + "releases held modifiers when losing %s focus", + (reason) => { + const display = document.createElement("div"); + display.tabIndex = 0; + document.body.append(display); + display.focus(); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const keyboard = new Guacamole.Keyboard(display) as Guacamole.Keyboard & { + press(keysym: number): boolean; + release(keysym: number): void; + pressed: Record; + }; + const sendKeyEvent = vi.fn(); + const state = { + keyboardRef: { current: keyboard }, + clientRef: { current: { sendKeyEvent } }, + displayElementRef: { current: display }, + isVisible: true, + windowFocusedRef: { current: true }, + hasKeyboardFocusRef: { current: true }, + }; + const refresh = () => runRefresh(state); + refresh(); + const keys = [0xffeb, 0xffe3, 0xffe9]; // Windows, Ctrl, Alt + keys.forEach((key) => keyboard.press(key)); + expect(sendKeyEvent.mock.calls).toEqual(keys.map((key) => [1, key])); + + if (reason === "window") state.windowFocusedRef.current = false; + if (reason === "tab") state.isVisible = false; + if (reason === "hidden") { + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + } + if (reason === "display") { + state.hasKeyboardFocusRef.current = false; + display.blur(); + } + refresh(); + expect(sendKeyEvent.mock.calls.slice(3)).toEqual( + [...keys].sort((a, b) => a - b).map((key) => [0, key]), + ); + expect(Object.keys(keyboard.pressed)).toEqual([]); + expect(keyboard.onkeydown).toBeNull(); + expect(keyboard.onkeyup).toBeNull(); + keyboard.press(0x6c); + keyboard.reset(); + expect(sendKeyEvent).toHaveBeenCalledTimes(6); + + state.isVisible = true; + state.windowFocusedRef.current = true; + state.hasKeyboardFocusRef.current = true; + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + display.focus(); + refresh(); + keyboard.press(0x6c); + keyboard.release(0x6c); + expect(sendKeyEvent.mock.calls.slice(6)).toEqual([ + [1, 0x6c], + [0, 0x6c], + ]); + }, + ); +});