Fix Desktop Xpra keyboard focus capture

Make the Desktop iframe explicitly focusable and re-arm Xpra keyboard capture on load and click so typed input reaches the remote session reliably.\n\nAdd regression assertions for the Xpra keyboard bridge contract.

Alessandro committed May 2, 2026 at 17:51 UTC 74dcb32814e77ba8a08f95639f168487af11f48e
3 files changed +115 -4
plugins/_office/webui/office-panel.html
+1
@@ -223,6 +223,7 @@
223 <iframe
224 class="office-desktop-frame"
225 data-office-desktop-frame
226 + tabindex="0"
227 :src="$store.office.officialOfficeUrl()"
228 aria-label="Desktop"
229 allow="clipboard-read; clipboard-write; autoplay"
plugins/_office/webui/office-store.js
+110 -4
@@ -213,6 +213,15 @@ function editorContainsFocus(element) {
213 return Boolean(element && active && (element === active || element.contains(active)));
214 }
215
216 +function isEditableInputTarget(target) {
217 + const element = target?.nodeType === 1 ? target : target?.parentElement;
218 + const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']");
219 + if (!editable) return false;
220 + if (editable.tagName !== "INPUT") return true;
221 + const type = String(editable.getAttribute("type") || "text").toLowerCase();
222 + return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
223 +}
224 +
225 function placeCaretAtEnd(element) {
226 if (!element) return;
227 if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
@@ -345,6 +354,8 @@ const model = {
354 _desktopResizePending: false,
355 _desktopPrimeTimer: null,
356 _desktopPrimeAttempts: 0,
357 + _desktopKeyboardActive: false,
358 + _desktopKeyboardCleanup: null,
359 _desktopStarting: null,
360
361 async init(element = null) {
@@ -385,6 +396,7 @@ const model = {
396 this.stopDesktopMonitor();
397 this.stopDesktopResizeObserver();
398 this.stopXpraDesktopPrime();
399 + this.stopDesktopKeyboardBridge();
400 this._floatingCleanup?.();
401 this._floatingCleanup = null;
402 if (this._mode === "modal") this._root = null;
@@ -973,9 +985,7 @@ const model = {
985 focusEditor(options = {}) {
986 if (!this.session || this.isPreviewOnly()) return false;
987 if (this.hasOfficialOffice()) {
976 - const frame = this.desktopFrame();
977 - frame?.focus?.({ preventScroll: true });
978 - return Boolean(frame);
988 + return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
989 }
990 const source = this._root?.querySelector?.("[data-office-source]");
991 const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
@@ -1070,14 +1080,44 @@ const model = {
1080 onDesktopFrameLoaded(event = null) {
1081 if (event?.target?.getAttribute?.("src") === "about:blank") return;
1082 this.error = "";
1073 - this.focusEditor({ end: false });
1083 + this.queueDesktopFrameFocus(event?.target || null);
1084 this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1085 },
1086
1087 + queueDesktopFrameFocus(frame = null) {
1088 + for (const delay of [0, 80, 260]) {
1089 + globalThis.setTimeout(() => {
1090 + if (!this.hasOfficialOffice()) return;
1091 + if (isEditableInputTarget(document.activeElement)) return;
1092 + this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true });
1093 + }, delay);
1094 + }
1095 + },
1096 +
1097 + focusDesktopFrame(frame = null, options = {}) {
1098 + const target = this.desktopFrame(frame);
1099 + if (!target) return false;
1100 + if (options.arm !== false) this._desktopKeyboardActive = true;
1101 + try {
1102 + target.setAttribute("tabindex", "0");
1103 + target.focus?.({ preventScroll: true });
1104 + target.contentWindow?.focus?.();
1105 + if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) {
1106 + target.contentDocument.body.tabIndex = -1;
1107 + }
1108 + target.contentDocument?.body?.focus?.({ preventScroll: true });
1109 + if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true;
1110 + } catch {
1111 + target.focus?.({ preventScroll: true });
1112 + }
1113 + return Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1114 + },
1115 +
1116 updateDesktopMonitor() {
1117 if (!this.hasOfficialOffice()) {
1118 this.stopDesktopMonitor();
1119 this.stopDesktopResizeObserver();
1120 + this._desktopKeyboardActive = false;
1121 return;
1122 }
1123 const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
@@ -1227,6 +1267,7 @@ const model = {
1267 const client = remoteWindow.client;
1268 if (!client) return false;
1269 this.installXpraDesktopClientPatches(remoteWindow, client);
1270 + this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1271 const container = client.container || remoteDocument?.querySelector?.("#screen");
1272 if (!container) return false;
1273
@@ -1464,6 +1505,71 @@ const model = {
1505 client.__a0XpraDesktopClientPatched = true;
1506 },
1507
1508 + installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
1509 + if (!frame || !remoteWindow || !remoteDocument || !client) return;
1510 + this.ensureDesktopKeyboardBridge();
1511 + frame.setAttribute("tabindex", "0");
1512 + if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return;
1513 +
1514 + const activate = () => this.focusDesktopFrame(frame, { arm: true });
1515 + const events = ["pointerdown", "mousedown", "touchstart", "focusin"];
1516 + for (const eventName of events) {
1517 + remoteDocument.addEventListener(eventName, activate, true);
1518 + }
1519 + remoteWindow.addEventListener("focus", activate, true);
1520 + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true;
1521 + remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => {
1522 + for (const eventName of events) {
1523 + remoteDocument.removeEventListener(eventName, activate, true);
1524 + }
1525 + remoteWindow.removeEventListener("focus", activate, true);
1526 + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false;
1527 + };
1528 + },
1529 +
1530 + ensureDesktopKeyboardBridge() {
1531 + if (this._desktopKeyboardCleanup) return;
1532 +
1533 + const deactivateWhenOutsideDesktop = (event) => {
1534 + const target = event.target;
1535 + if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return;
1536 + this._desktopKeyboardActive = false;
1537 + };
1538 + const forwardKeyboardEvent = (event, pressed) => {
1539 + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1540 + if (event.defaultPrevented || isEditableInputTarget(event.target)) return;
1541 +
1542 + const frame = this.desktopFrame();
1543 + if (!frame || document.activeElement === frame) return;
1544 + const client = frame.contentWindow?.client;
1545 + const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
1546 + if (!client?.capture_keyboard || typeof handler !== "function") return;
1547 +
1548 + const allowDefault = handler.call(client, event);
1549 + if (!allowDefault) {
1550 + event.preventDefault();
1551 + event.stopPropagation();
1552 + }
1553 + };
1554 + const onKeydown = (event) => forwardKeyboardEvent(event, true);
1555 + const onKeyup = (event) => forwardKeyboardEvent(event, false);
1556 +
1557 + document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1558 + document.addEventListener("keydown", onKeydown, true);
1559 + document.addEventListener("keyup", onKeyup, true);
1560 + this._desktopKeyboardCleanup = () => {
1561 + document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1562 + document.removeEventListener("keydown", onKeydown, true);
1563 + document.removeEventListener("keyup", onKeyup, true);
1564 + this._desktopKeyboardActive = false;
1565 + this._desktopKeyboardCleanup = null;
1566 + };
1567 + },
1568 +
1569 + stopDesktopKeyboardBridge() {
1570 + this._desktopKeyboardCleanup?.();
1571 + },
1572 +
1573 queueDesktopResize(options = {}) {
1574 if (!this.hasOfficialOffice()) return;
1575 const token = this.session?.desktop?.token || "";
tests/test_office_canvas_setup.py
+4
@@ -50,6 +50,10 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
50 assert "primeXpraDesktopFrame" in store
51 assert "normalizeXpraDesktopWindow" in store
52 assert "installXpraDesktopWheelBridge" in store
53 + assert "installXpraDesktopKeyboardBridge" in store
54 + assert "focusDesktopFrame" in store
55 + assert "_desktopKeyboardActive" in store
56 + assert "isEditableInputTarget" in store
57 assert "reloadDesktopFrame" in store
58 assert 'result?.reload' in store
59 assert "a0_reload" in store