fix(remote-desktop): release held keys on focus loss (#1498)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:45:29 -05:00
1 parent a4a4d4f27b
commit fb28cd1f21
2 files changed
+108 -12

No files matched your search

@@ -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]);
@@ -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<string, unknown>) => 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<number, boolean>;
};
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],
]);
},
);
});