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