fix(desktop): stabilize Xpra viewport resizing

Make Desktop canvas and modal handoffs resize the live Xpra viewport deterministically by syncing the visible frame, making backend resize requests authoritative, unloading hidden iframe clients, and guarding Xpra HTML menu callbacks when the menu is disabled. Also forwards wheel events from the embedded Xpra canvas so mouse and trackpad scrolling reach the Linux desktop session.

Alessandro committed May 2, 2026 at 13:10 UTC 1a32d3a295c79151587223e1dfdc882dcfe6859a
2 files changed +212 -21
plugins/_office/webui/office-store.js
+185 -20
@@ -338,6 +338,7 @@ const model = {
338 _desktopHeartbeatTabId: "",
339 _desktopHeartbeatMisses: 0,
340 _desktopResizeCleanup: null,
341 + _desktopResizeTarget: null,
342 _desktopResizeTimer: null,
343 _desktopResizeKey: "",
344 _desktopResizeSuspended: false,
@@ -371,13 +372,12 @@ const model = {
372 await this.ensureDesktopSession({ select: !this.session });
373 }
374 this.restoreDesktopFrames();
375 + this.requestDesktopViewportSync({ force: true });
376 },
377
378 beforeHostHidden(options = {}) {
379 this.flushInput();
378 - if (options?.unloadDesktop) {
379 - this.unloadDesktopFrames();
380 - }
380 + this.unloadDesktopFrames();
381 },
382
383 cleanup() {
@@ -1071,10 +1071,7 @@ const model = {
1071 if (event?.target?.getAttribute?.("src") === "about:blank") return;
1072 this.error = "";
1073 this.focusEditor({ end: false });
1074 - this.startDesktopResizeObserver();
1075 - this.primeXpraDesktopFrame({ reset: true, frame: event?.target || null });
1076 - this.queueDesktopResize();
1077 - this.updateDesktopMonitor();
1074 + this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1075 },
1076
1077 updateDesktopMonitor() {
@@ -1097,11 +1094,18 @@ const model = {
1094 },
1095
1096 startDesktopResizeObserver() {
1100 - this.stopDesktopResizeObserver();
1101 - if (!this.hasOfficialOffice()) return;
1097 + if (!this.hasOfficialOffice()) {
1098 + this.stopDesktopResizeObserver();
1099 + return;
1100 + }
1101 const frame = this.desktopFrame();
1102 const target = frame?.parentElement || frame;
1104 - if (!target) return;
1103 + if (!target) {
1104 + this.stopDesktopResizeObserver();
1105 + return;
1106 + }
1107 + if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return;
1108 + this.stopDesktopResizeObserver();
1109
1110 const resize = () => this.queueDesktopResize();
1111 const resizeStart = () => this.suspendDesktopResize();
@@ -1118,6 +1122,7 @@ const model = {
1122 cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1123 globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1124 cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1125 + this._desktopResizeTarget = target;
1126 this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1127 resize();
1128 },
@@ -1129,6 +1134,7 @@ const model = {
1134 this._desktopResizeTimer = null;
1135 this._desktopResizeCleanup?.();
1136 this._desktopResizeCleanup = null;
1137 + this._desktopResizeTarget = null;
1138 this._desktopResizeKey = "";
1139 this._desktopResizeSuspended = false;
1140 this._desktopResizePending = false;
@@ -1159,6 +1165,35 @@ const model = {
1165 );
1166 },
1167
1168 + requestDesktopViewportSync(options = {}) {
1169 + const run = (force = false) => {
1170 + this.syncDesktopViewport({ ...options, force });
1171 + };
1172 + if (globalThis.requestAnimationFrame) {
1173 + globalThis.requestAnimationFrame(() => run(Boolean(options.force)));
1174 + } else {
1175 + globalThis.setTimeout(() => run(Boolean(options.force)), 0);
1176 + }
1177 + for (const delay of [140, 420]) {
1178 + globalThis.setTimeout(() => run(false), delay);
1179 + }
1180 + },
1181 +
1182 + syncDesktopViewport(options = {}) {
1183 + if (!this.hasOfficialOffice()) return false;
1184 + const frame = this.desktopFrame(options.frame || null);
1185 + if (!frame) return false;
1186 + this.startDesktopResizeObserver();
1187 + this.primeXpraDesktopFrame({ reset: true, frame });
1188 + this.queueDesktopResize({
1189 + force: Boolean(options.force),
1190 + serverResize: options.serverResize !== false,
1191 + frame,
1192 + });
1193 + this.updateDesktopMonitor();
1194 + return true;
1195 + },
1196 +
1197 primeXpraDesktopFrame(options = {}) {
1198 if (options.reset) {
1199 this.stopXpraDesktopPrime();
@@ -1183,13 +1218,15 @@ const model = {
1218 const frame = this.desktopFrame(preferredFrame);
1219 const remoteWindow = frame?.contentWindow;
1220 if (!remoteWindow) return false;
1186 - const requestServerResize = options.requestServerResize !== false;
1221 + const requestServerResize = options.requestServerResize === true;
1222 const requestRefresh = options.requestRefresh !== false;
1223 try {
1224 const remoteDocument = frame.contentDocument || remoteWindow.document;
1225 this.installXpraDesktopFrameCss(remoteDocument);
1226 + this.installXpraDesktopFramePatches(remoteWindow, remoteDocument);
1227 const client = remoteWindow.client;
1228 if (!client) return false;
1229 + this.installXpraDesktopClientPatches(remoteWindow, client);
1230 const container = client.container || remoteDocument?.querySelector?.("#screen");
1231 if (!container) return false;
1232
@@ -1202,10 +1239,19 @@ const model = {
1239
1240 const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1241 const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1242 + if (width > 0 && height > 0) {
1243 + client.desktop_width = width;
1244 + client.desktop_height = height;
1245 + }
1246 if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1247 client.desktop_width = 0;
1248 client.desktop_height = 0;
1208 - client._screen_resized(new remoteWindow.Event("resize"));
1249 + client.__a0AllowScreenResize = true;
1250 + try {
1251 + client._screen_resized(new remoteWindow.Event("resize"));
1252 + } finally {
1253 + client.__a0AllowScreenResize = false;
1254 + }
1255 }
1256
1257 for (const xpraWindow of windows) {
@@ -1214,6 +1260,7 @@ const model = {
1260 this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1261 xpraWindow.updateCSSGeometry?.();
1262 this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1263 + this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1264 if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1265 }
1266 return true;
@@ -1261,6 +1308,81 @@ const model = {
1308 canvas?.style?.setProperty("margin", "0", "important");
1309 },
1310
1311 + installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
1312 + const canvas = xpraWindow?.canvas;
1313 + if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return;
1314 + if (typeof xpraWindow.mouse_scroll_cb !== "function") return;
1315 + canvas.__a0XpraWheelBridgeInstalled = true;
1316 + canvas.addEventListener("wheel", (event) => {
1317 + event.stopImmediatePropagation?.();
1318 + event.stopPropagation?.();
1319 + event.preventDefault?.();
1320 + const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event);
1321 + xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow);
1322 + }, { passive: false, capture: true });
1323 + },
1324 +
1325 + xpraDesktopWheelEvent(remoteWindow, canvas, event) {
1326 + const finite = (value, fallback = 0) => {
1327 + const number = Number(value);
1328 + return Number.isFinite(number) ? number : fallback;
1329 + };
1330 + const deltaMode = finite(event.deltaMode, 0);
1331 + const lineHeight = 16;
1332 + const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800);
1333 + const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1;
1334 + const deltaX = finite(event.deltaX) * deltaScale;
1335 + const deltaY = finite(event.deltaY) * deltaScale;
1336 + const deltaZ = finite(event.deltaZ) * deltaScale;
1337 + const wheelDeltaX = finite(event.wheelDeltaX, -deltaX);
1338 + const wheelDeltaY = finite(event.wheelDeltaY, -deltaY);
1339 + const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX);
1340 + const getModifierState = (key) => {
1341 + if (typeof event.getModifierState === "function") return event.getModifierState(key);
1342 + const normalizedKey = String(key || "").toLowerCase();
1343 + if (normalizedKey === "alt") return Boolean(event.altKey);
1344 + if (normalizedKey === "control") return Boolean(event.ctrlKey);
1345 + if (normalizedKey === "meta") return Boolean(event.metaKey);
1346 + if (normalizedKey === "shift") return Boolean(event.shiftKey);
1347 + return false;
1348 + };
1349 + const normalizedEvent = Object.create(event);
1350 + Object.defineProperties(normalizedEvent, {
1351 + target: { value: event.target || canvas },
1352 + currentTarget: { value: canvas },
1353 + clientX: { value: finite(event.clientX) },
1354 + clientY: { value: finite(event.clientY) },
1355 + pageX: { value: finite(event.pageX, finite(event.clientX)) },
1356 + pageY: { value: finite(event.pageY, finite(event.clientY)) },
1357 + screenX: { value: finite(event.screenX) },
1358 + screenY: { value: finite(event.screenY) },
1359 + offsetX: { value: finite(event.offsetX) },
1360 + offsetY: { value: finite(event.offsetY) },
1361 + movementX: { value: finite(event.movementX) },
1362 + movementY: { value: finite(event.movementY) },
1363 + button: { value: finite(event.button) },
1364 + buttons: { value: finite(event.buttons) },
1365 + which: { value: finite(event.which) },
1366 + detail: { value: finite(event.detail) },
1367 + deltaX: { value: deltaX },
1368 + deltaY: { value: deltaY },
1369 + deltaZ: { value: deltaZ },
1370 + deltaMode: { value: 0 },
1371 + wheelDeltaX: { value: wheelDeltaX },
1372 + wheelDeltaY: { value: wheelDeltaY },
1373 + wheelDelta: { value: wheelDelta },
1374 + altKey: { value: Boolean(event.altKey) },
1375 + ctrlKey: { value: Boolean(event.ctrlKey) },
1376 + metaKey: { value: Boolean(event.metaKey) },
1377 + shiftKey: { value: Boolean(event.shiftKey) },
1378 + getModifierState: { value: getModifierState },
1379 + preventDefault: { value: () => event.preventDefault?.() },
1380 + stopPropagation: { value: () => event.stopPropagation?.() },
1381 + stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() },
1382 + });
1383 + return normalizedEvent;
1384 + },
1385 +
1386 installXpraDesktopFrameCss(remoteDocument) {
1387 if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1388 const style = remoteDocument.createElement("style");
@@ -1307,13 +1429,49 @@ const model = {
1429 remoteDocument.head?.appendChild(style);
1430 },
1431
1432 + installXpraDesktopFramePatches(remoteWindow, remoteDocument) {
1433 + if (!remoteWindow || !remoteDocument) return;
1434 + remoteWindow.__a0XpraDesktopFramePatches ||= {};
1435 + const patches = remoteWindow.__a0XpraDesktopFramePatches;
1436 + if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") {
1437 + const originalNoWindowList = remoteWindow.noWindowList;
1438 + remoteWindow.noWindowList = function patchedNoWindowList(...args) {
1439 + if (!remoteDocument.querySelector("#open_windows")) return undefined;
1440 + return originalNoWindowList.apply(this, args);
1441 + };
1442 + patches.noWindowList = true;
1443 + }
1444 + if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") {
1445 + const originalAddWindowListItem = remoteWindow.addWindowListItem;
1446 + remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) {
1447 + if (!remoteDocument.querySelector("#open_windows_list")) return undefined;
1448 + return originalAddWindowListItem.apply(this, args);
1449 + };
1450 + patches.addWindowListItem = true;
1451 + }
1452 + },
1453 +
1454 + installXpraDesktopClientPatches(remoteWindow, client) {
1455 + if (!remoteWindow || !client || client.__a0XpraDesktopClientPatched) return;
1456 + if (typeof client._screen_resized === "function") {
1457 + const originalScreenResized = client._screen_resized.bind(client);
1458 + client.__a0OriginalScreenResized = originalScreenResized;
1459 + client._screen_resized = function patchedScreenResized(event) {
1460 + if (client.__a0AllowScreenResize === true) return originalScreenResized(event);
1461 + return false;
1462 + };
1463 + }
1464 + client.__a0XpraDesktopClientPatched = true;
1465 + },
1466 +
1467 queueDesktopResize(options = {}) {
1468 if (!this.hasOfficialOffice()) return;
1469 const token = this.session?.desktop?.token || "";
1313 - const frame = this.desktopFrame();
1470 + const frame = this.desktopFrame(options.frame || null);
1471 const target = frame?.parentElement || frame;
1472 if (!token || !target) return;
1473 const force = Boolean(options.force);
1474 + const serverResize = options.serverResize !== false;
1475 const rect = target.getBoundingClientRect();
1476 const width = Math.round(rect.width);
1477 const height = Math.round(rect.height);
@@ -1324,11 +1482,8 @@ const model = {
1482 return;
1483 }
1484 const key = `${token}:${width}x${height}`;
1485 + if (!serverResize) return;
1486 if (!force && key === this._desktopResizeKey) return;
1328 - if (options.serverResize !== true) {
1329 - this._desktopResizeKey = key;
1330 - return;
1331 - }
1487 if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
1488 this._desktopResizeTimer = globalThis.setTimeout(async () => {
1489 this._desktopResizeTimer = null;
@@ -1342,10 +1497,20 @@ const model = {
1497 if (response.ok) {
1498 const result = await response.json().catch(() => ({}));
1499 this._desktopResizeKey = key;
1345 - if (result?.reload) {
1346 - this.reloadDesktopFrame(frame);
1500 + const activeFrame = this.desktopFrame(frame);
1501 + const activeTarget = activeFrame?.parentElement || activeFrame;
1502 + const activeRect = activeTarget?.getBoundingClientRect?.();
1503 + const activeWidth = Math.round(activeRect?.width || 0);
1504 + const activeHeight = Math.round(activeRect?.height || 0);
1505 + if (activeWidth >= 320 && activeHeight >= 220) {
1506 + const activeKey = `${token}:${activeWidth}x${activeHeight}`;
1507 + if (activeKey !== key) {
1508 + this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame });
1509 + return;
1510 + }
1511 }
1348 - this.primeXpraDesktopFrame({ reset: true });
1512 + if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
1513 + this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame });
1514 }
1515 } catch (error) {
1516 console.warn("Desktop resize skipped", error);
tests/test_office_canvas_setup.py
+27 -1
@@ -49,19 +49,30 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
49 assert "restoreDesktopFrames" in store
50 assert "primeXpraDesktopFrame" in store
51 assert "normalizeXpraDesktopWindow" in store
52 + assert "installXpraDesktopWheelBridge" in store
53 assert "reloadDesktopFrame" in store
54 assert 'result?.reload' in store
55 assert "a0_reload" in store
56 assert "const DESKTOP_RESIZE_DELAY_MS = 80" in store
57 assert "requestServerResize: false" in store
58 assert "requestRefresh: false" in store
58 - assert "options.serverResize !== true" in store
59 + assert "_desktopResizeTarget" in store
60 + assert "requestDesktopViewportSync" in store
61 + assert "syncDesktopViewport" in store
62 + assert "options.serverResize !== false" in store
63 + assert "serverResize: true" in store
64 assert "server_is_desktop = true" in store
65 assert "server_resize_exact = true" in store
66 assert "_set_decorated?.(false)" in store
67 assert "topoffset = 0" in store
68 assert ".undecorated" in store
69 assert "a0-xpra-desktop-frame-css" in store
70 + assert "installXpraDesktopFramePatches" in store
71 + assert "installXpraDesktopClientPatches" in store
72 + assert "patchedNoWindowList" in store
73 + assert "patchedAddWindowListItem" in store
74 + assert "patchedScreenResized" in store
75 + assert "__a0AllowScreenResize" in store
76 assert "_desktopHeartbeatTimer" in store
77 assert "office-modal-focus-button" in store
78 assert "officialOfficeUrl" in store
@@ -82,6 +93,21 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
93 assert "office-log" not in panel
94
95
96 +def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
97 + store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
98 + encoding="utf-8",
99 + )
100 +
101 + assert "canvas.addEventListener(\"wheel\"" in store
102 + assert "mouse_scroll_cb(normalizedEvent, xpraWindow)" in store
103 + assert "stopImmediatePropagation" in store
104 + assert "{ passive: false, capture: true }" in store
105 + assert "xpraDesktopWheelEvent" in store
106 + assert "deltaMode: { value: 0 }" in store
107 + assert "wheelDeltaY" in store
108 + assert "getModifierState: { value: getModifierState }" in store
109 +
110 +
111 def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
112 panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
113 encoding="utf-8",