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",