diff --git a/static/client.html b/static/client.html
index fa8acb2..cf47719 100644
--- a/static/client.html
+++ b/static/client.html
@@ -186,6 +186,7 @@
+
@@ -953,58 +954,18 @@
}, 500);
});
- // Mouse: map the pointer from popup-window pixels to this
- // monitor's native pixels using the canvas's LIVE on-screen
- // geometry (getBoundingClientRect), then offset into the combined
- // framebuffer. Reading the live rect each event avoids any stale
- // cached scale/offset. Sent raw; guacd maps the combined
- // coordinate back to this head.
- // Native pointer handling (bypasses Guacamole.Mouse, which does
- // not track the X axis correctly in a popup window). Map the raw
- // client coordinate into the canvas's live on-screen rect as a
- // 0..1 fraction, then into this monitor's native pixels and the
- // combined framebuffer. Fraction-based, so it self-recalibrates
- // on any resize.
- function popupPointer(ev) {
- var rect = monitorLayout[index];
- if (!rect) return;
- var cr = canvas.getBoundingClientRect();
- var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
- var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
- if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
- if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
- client.sendMouseState(new Guacamole.Mouse.State({
- x: rect.left + fx * rect.width,
- y: rect.top + fy * rect.height,
- left: (ev.buttons & 1) !== 0,
- middle: (ev.buttons & 4) !== 0,
- right: (ev.buttons & 2) !== 0,
- up: false, down: false
- }), false);
- ev.preventDefault();
- }
- canvas.addEventListener('mousedown', popupPointer);
- canvas.addEventListener('mousemove', popupPointer);
- canvas.addEventListener('mouseup', popupPointer);
- canvas.addEventListener('contextmenu', function(ev) { ev.preventDefault(); });
- // Scroll wheel -> momentary up/down button.
- canvas.addEventListener('wheel', function(ev) {
- var rect = monitorLayout[index];
- if (!rect) return;
- var cr = canvas.getBoundingClientRect();
- var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
- var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
- if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
- if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
- var base = { x: rect.left + fx * rect.width, y: rect.top + fy * rect.height,
- left: false, middle: false, right: false, up: false, down: false };
- var dir = ev.deltaY < 0 ? 'up' : 'down';
- base[dir] = true;
- client.sendMouseState(new Guacamole.Mouse.State(base), false);
- base[dir] = false;
- client.sendMouseState(new Guacamole.Mouse.State(base), false);
- ev.preventDefault();
- }, { passive: false });
+ // Pointer, wheel and context menu. Guacamole.Mouse is not
+ // used here -- it does not track the X axis correctly in a
+ // popup -- so the mapping, the repeat suppression and the
+ // coalesced replay live in Guacamole.MonitorPointer, where a
+ // test can reach them. The rect is read per event rather than
+ // captured, because the layout changes under a running window.
+ Guacamole.MonitorPointer(canvas,
+ function () { return monitorLayout[index]; },
+ function (state) {
+ client.sendMouseState(new Guacamole.Mouse.State(state),
+ false);
+ });
// Keyboard: forward to the same client (combined session).
var keyboard = new Guacamole.Keyboard(win.document);
diff --git a/static/guac/MonitorPointer.js b/static/guac/MonitorPointer.js
new file mode 100644
index 0000000..ef2333f
--- /dev/null
+++ b/static/guac/MonitorPointer.js
@@ -0,0 +1,178 @@
+/*
+ * Pointer handling for a pop-out monitor window.
+ *
+ * These windows do not use Guacamole.Mouse -- it does not track the X axis
+ * correctly in a popup -- so they map the pointer themselves: the raw client
+ * coordinate into the canvas's live on-screen rect as a 0..1 fraction, then
+ * into this monitor's native pixels and on into the combined framebuffer.
+ * Fraction-based and read from getBoundingClientRect() on every event, so it
+ * self-recalibrates on any resize rather than caching a scale that goes stale.
+ *
+ * It lives in its own file because the alternative is untestable. There is no
+ * way to open one of these windows without a live multi-monitor session, so
+ * inline in the page the only thing a test could do is match the source text
+ * and hope -- and the two things most worth checking here are a coordinate
+ * mapping and a suppression rule, which are exactly the kind that look right
+ * and are off by a term.
+ *
+ * Copyright (C) 2026 Sol1 Pty Ltd. Licensed under Apache 2.0.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Attaches pointer, wheel and context-menu handling to a pop-out monitor's
+ * canvas.
+ *
+ * @constructor
+ * @param {!Element} canvas
+ * The canvas this monitor is drawn on, in the pop-out window's document.
+ *
+ * @param {!function(): ?Object} getRect
+ * This monitor's rectangle within the combined framebuffer, as
+ * {left, top, width, height}, or null if the layout does not have one yet.
+ * Called per event rather than captured, because the layout changes under
+ * a running window.
+ *
+ * @param {!function(!Object)} sendState
+ * Called with a plain mouse state -- {x, y, left, middle, right, up, down}
+ * -- to be forwarded to the session.
+ */
+Guacamole.MonitorPointer = function MonitorPointer(canvas, getRect, sendState) {
+
+ /**
+ * The last state sent, as a key, so the same one is not sent twice.
+ *
+ * Guacamole.Mouse drops an unchanged position inside move(); this path has
+ * no such check and needs one, because the coalesced replay below delivers
+ * the dispatched position and the mousemove that follows delivers it
+ * again. Keyed on the buttons as well as the position: a press and a
+ * release happen at one place and are not repeats of each other.
+ *
+ * @private
+ * @type {?string}
+ */
+ var lastSent = null;
+
+ /**
+ * Maps an event's client coordinate into the combined framebuffer and
+ * sends it, unless it is a repeat of what was last sent.
+ *
+ * @private
+ * @param {!Object} ev
+ */
+ function pointer(ev) {
+
+ var rect = getRect();
+ if (!rect)
+ return;
+
+ var cr = canvas.getBoundingClientRect();
+ var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
+ var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
+ if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
+ if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
+
+ var state = {
+ x : rect.left + fx * rect.width,
+ y : rect.top + fy * rect.height,
+ left : (ev.buttons & 1) !== 0,
+ middle : (ev.buttons & 4) !== 0,
+ right : (ev.buttons & 2) !== 0,
+ up : false,
+ down : false
+ };
+
+ var key = state.x + ',' + state.y + ',' + ev.buttons;
+ if (key !== lastSent) {
+ lastSent = key;
+ sendState(state);
+ }
+
+ if (ev.preventDefault)
+ ev.preventDefault();
+
+ }
+
+ /* Replay the moves the browser merged away during a drag, on the same
+ * terms as Guacamole.Mouse does for the main display: a busy main thread
+ * discards pointer movement rather than delaying it, and a dragged
+ * scrollbar thumb is dropped rather than lagged. Additive -- the mousemove
+ * listener below is unchanged and still delivers every position it did,
+ * and the one they both deliver is dropped by the check above.
+ *
+ * Feature-detected on the event rather than on PointerEvent, because a
+ * pop-out window is a realm of its own: the constructor the page that
+ * opened it can see is not the one that made this event. */
+ canvas.addEventListener('pointermove', function (ev) {
+
+ if (ev.pointerType !== 'mouse' || !ev.buttons)
+ return;
+
+ if (!Guacamole.Mouse.coalescedMovement
+ || typeof ev.getCoalescedEvents !== 'function')
+ return;
+
+ var merged = ev.getCoalescedEvents();
+ if (!merged || merged.length <= 1)
+ return;
+
+ var events = Guacamole.Mouse.sampleCoalesced(merged);
+ for (var i = 0; i < events.length; i++)
+ pointer(events[i]);
+
+ });
+
+ canvas.addEventListener('mousedown', pointer);
+ canvas.addEventListener('mousemove', pointer);
+ canvas.addEventListener('mouseup', pointer);
+
+ canvas.addEventListener('contextmenu', function (ev) {
+ ev.preventDefault();
+ });
+
+ /* Scroll wheel -> momentary up/down button. */
+ canvas.addEventListener('wheel', function (ev) {
+
+ var rect = getRect();
+ if (!rect)
+ return;
+
+ var cr = canvas.getBoundingClientRect();
+ var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
+ var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
+ if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
+ if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
+
+ var dir = ev.deltaY < 0 ? 'up' : 'down';
+
+ /* Two states, not one mutated between the sends. Sending the same
+ * object twice and changing it in between only works while every
+ * consumer copies it synchronously -- client.html does, by wrapping it
+ * in a Guacamole.Mouse.State -- and silently sends the wrong thing to
+ * one that does not. A momentary button press is exactly the shape
+ * where that would be missed, since the second state is the one that
+ * survives. */
+ function wheelState(pressed) {
+ var state = {
+ x : rect.left + fx * rect.width,
+ y : rect.top + fy * rect.height,
+ left : false, middle : false, right : false,
+ up : false, down : false
+ };
+ state[dir] = pressed;
+ return state;
+ }
+
+ sendState(wheelState(true));
+ sendState(wheelState(false));
+
+ /* Sent behind pointer()'s back, so what it last saw is no longer what
+ * the session last got. */
+ lastSent = null;
+
+ ev.preventDefault();
+
+ }, { passive : false });
+
+};
diff --git a/static/guac/Mouse.js b/static/guac/Mouse.js
index 083616e..84064f5 100644
--- a/static/guac/Mouse.js
+++ b/static/guac/Mouse.js
@@ -136,6 +136,71 @@ Guacamole.Mouse = function Mouse(element) {
}, false);
+ /* Recovers the moves the browser merged away.
+ *
+ * A busy main thread does not delay pointer movement, it *discards* it:
+ * moves that arrive while a handler is running are coalesced into one, and
+ * what is dispatched afterwards carries only the last position. A drag
+ * feels that immediately -- the path between two points is gone, so a
+ * dragged scrollbar thumb is dropped rather than merely lagged -- while a
+ * hover or a video degrades gracefully under the same load, which is why
+ * this is worth doing for one and not the other.
+ *
+ * getCoalescedEvents() returns the moves that were merged, the dispatched
+ * one last. Replaying them in order restores the path. It does not restore
+ * the timing: nothing running on a blocked thread can, and the positions
+ * still arrive together once the thread is free. The remote end sees where
+ * the pointer went rather than only where it ended up.
+ *
+ * This is deliberately additive. The mousemove listener above is untouched
+ * and still delivers every position it did before, so touch, the
+ * compatibility mouse events it produces and the ignore_mouse counter that
+ * swallows them all behave exactly as they did. The dispatched position
+ * arrives here first and again there, and the second is dropped by the
+ * unchanged-coordinates check in move(). Migrating this file to pointer
+ * events outright would be the tidier shape and a much larger change:
+ * every listener would have to move together, or synthetic mouse events
+ * from touch would arrive with nothing left counting them down. */
+ if (typeof PointerEvent !== 'undefined'
+ && typeof PointerEvent.prototype.getCoalescedEvents === 'function'
+ && Guacamole.Mouse.coalescedMovement) {
+
+ element.addEventListener("pointermove", function(e) {
+
+ /* Touch has its own handling -- Guacamole.Touch and
+ * Guacamole.Mouse.Touchscreen -- and reaches the listener above
+ * as compatibility mouse events, which the counter below is there
+ * to swallow. Pen does not coalesce meaningfully. */
+ if (e.pointerType !== 'mouse')
+ return;
+
+ /* Recently touched: the listener above is counting synthetic
+ * events down and this must not interfere with either the count
+ * or what it is suppressing. */
+ if (ignore_mouse)
+ return;
+
+ /* Only while a button is held. The path matters for a drag and
+ * the endpoint is all that matters for a hover, and replaying
+ * every merged move of an idle pointer would multiply the
+ * upstream mouse traffic of a session that had nothing wrong with
+ * it. */
+ if (!e.buttons)
+ return;
+
+ var merged = e.getCoalescedEvents();
+ if (!merged || merged.length <= 1)
+ return;
+
+ var events = Guacamole.Mouse.sampleCoalesced(merged);
+ for (var i = 0; i < events.length; i++)
+ guac_mouse.move(Guacamole.Position.fromClientPosition(element,
+ events[i].clientX, events[i].clientY), events[i]);
+
+ }, false);
+
+ }
+
element.addEventListener("mousedown", function(e) {
// Do not handle if ignoring events
@@ -340,6 +405,85 @@ Guacamole.Mouse = function Mouse(element) {
};
+/**
+ * Whether the moves the browser coalesced away during a drag are replayed, so
+ * that the remote end sees the path the pointer took rather than only where it
+ * ended up. See the pointermove listener above for what this does and does
+ * not recover.
+ *
+ * Settable as window.__mouseCoalesced, or as the mouseCoalesced key in
+ * localStorage to survive a reconnect -- which is the form that works on a
+ * live session, since client.html rebuilds its own URL on every launch and
+ * relaunch and drops anything added to it by hand.
+ *
+ * @type {!boolean}
+ */
+Guacamole.Mouse.coalescedMovement = (function coalescedMovement() {
+
+ if (typeof window === 'undefined')
+ return true;
+
+ if (window.__mouseCoalesced !== undefined)
+ return !!window.__mouseCoalesced;
+
+ try {
+ var stored = window.localStorage
+ && window.localStorage.getItem('mouseCoalesced');
+ if (stored === 'off' || stored === 'false')
+ return false;
+ } catch (e) {
+ /* Storage can be blocked outright; the global still works. */
+ }
+
+ return true;
+
+}());
+
+/**
+ * The merged moves to replay, at most COALESCED_MAX of them, in order.
+ *
+ * A pointer reporting at 1000Hz behind a thread blocked for 100ms offers a
+ * hundred positions for one drag, and forwarding each as its own instruction
+ * would answer a rendering problem with a traffic one. Sampling evenly, and
+ * always keeping the last, holds the shape of the path at a bounded cost; an
+ * ordinary pointer never reaches the cap at all.
+ *
+ * @param {!Array} merged
+ * What getCoalescedEvents() returned.
+ *
+ * @returns {!Array}
+ * The events to replay, in order.
+ */
+Guacamole.Mouse.sampleCoalesced = function sampleCoalesced(merged) {
+
+ if (merged.length <= Guacamole.Mouse.COALESCED_MAX)
+ return merged;
+
+ var out = [];
+ var step = merged.length / Guacamole.Mouse.COALESCED_MAX;
+
+ for (var i = 0; i < Guacamole.Mouse.COALESCED_MAX - 1; i++)
+ out.push(merged[Math.floor(i * step)]);
+
+ /* The last is where the pointer actually is, so it is never the one
+ * dropped. */
+ out.push(merged[merged.length - 1]);
+
+ return out;
+
+};
+
+/**
+ * The most merged moves replayed for one dispatched move. A pointer reporting
+ * at 1000Hz behind a thread blocked for 100ms offers a hundred positions for a
+ * single drag, and forwarding each as its own instruction would answer a
+ * rendering problem with a traffic one. An ordinary pointer never reaches
+ * this.
+ *
+ * @type {!number}
+ */
+Guacamole.Mouse.COALESCED_MAX = 16;
+
/**
* The current state of a mouse, including position and buttons.
*
diff --git a/tests/browser/drive-pointer.mjs b/tests/browser/drive-pointer.mjs
new file mode 100644
index 0000000..9c16c43
--- /dev/null
+++ b/tests/browser/drive-pointer.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/*
+ * Drives real pointer input at the pop-out monitor harness through the
+ * debugger, with the main thread deliberately blocked, so that the coalesced
+ * replay is exercised against the browser's own getCoalescedEvents().
+ *
+ * The node tests feed it events shaped the way the code expects. This checks
+ * the assumption underneath them: that the browser merges moves at all when
+ * the thread is busy, and that what it hands back replays in order and inside
+ * the monitor it belongs to.
+ *
+ * Needs a Chrome listening on a debugging port and the harness server:
+ * node tests/browser/serve.mjs
+ * node tests/browser/drive-pointer.mjs [--port 9333]
+ */
+
+const args = process.argv.slice(2);
+const portAt = args.indexOf('--port');
+const port = portAt === -1 ? 9333 : +args[portAt + 1];
+
+async function connect(wsUrl) {
+ const ws = new WebSocket(wsUrl);
+ await new Promise((ok, bad) => { ws.onopen = ok; ws.onerror = bad; });
+ let next = 1;
+ const waiting = new Map();
+ ws.onmessage = (m) => {
+ const msg = JSON.parse(m.data);
+ if (msg.id && waiting.has(msg.id)) {
+ const { ok, bad } = waiting.get(msg.id);
+ waiting.delete(msg.id);
+ msg.error ? bad(new Error(msg.error.message)) : ok(msg.result);
+ }
+ };
+ return (method, params = {}) => new Promise((ok, bad) => {
+ const id = next++;
+ waiting.set(id, { ok, bad });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
+const browser = await connect(version.webSocketDebuggerUrl);
+const { targetId } = await browser('Target.createTarget',
+ { url: 'http://127.0.0.1:8099/monitor-pointer.html' });
+const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+const page = await connect(list.find((t) => t.id === targetId).webSocketDebuggerUrl);
+
+await page('Runtime.enable');
+await new Promise((r) => setTimeout(r, 1200));
+
+const read = async (expression) => JSON.parse((await page('Runtime.evaluate',
+ { expression, returnByValue: true })).result.value);
+
+let failures = 0;
+const check = (name, ok, detail = '') => {
+ if (ok) console.log(` ok ${name}`);
+ else { failures++; console.log(` FAIL ${name}${detail ? '\n ' + detail : ''}`); }
+};
+
+console.log('pop-out monitor pointer, real browser');
+
+/* Press, then drag across the canvas while the main thread is busy, then
+ * release. The block is what makes the browser coalesce. */
+await page('Input.dispatchMouseEvent', { type: 'mousePressed', x: 150, y: 130,
+ button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse' });
+
+page('Runtime.evaluate', { expression: 'window.__block(150)' });
+const moves = [];
+for (let i = 0; i < 60; i++)
+ moves.push(page('Input.dispatchMouseEvent', { type: 'mouseMoved',
+ x: 150 + i * 10, y: 130 + i * 4, buttons: 1,
+ button: 'left', pointerType: 'mouse' }));
+await Promise.all(moves);
+
+await new Promise((r) => setTimeout(r, 700));
+await page('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 740, y: 366,
+ button: 'left', buttons: 0, clickCount: 1, pointerType: 'mouse' });
+await new Promise((r) => setTimeout(r, 300));
+
+const sent = await read('JSON.stringify(window.__sent)');
+const maxCoalesced = await read('JSON.stringify(window.__maxCoalesced)');
+
+console.log(` (the browser merged up to ${maxCoalesced} moves into one event;`
+ + ` ${sent.length} states were sent)`);
+
+check('the browser really did coalesce', maxCoalesced > 1,
+ `max coalesced was ${maxCoalesced} -- the thread was not busy enough, so`
+ + ' this run proves nothing either way');
+
+check('real pointer input produced states', sent.length > 0);
+
+check('every position lands inside this monitor',
+ sent.every((s) => s.x >= 2560 && s.x <= 2560 + 1920
+ && s.y >= 0 && s.y <= 1080),
+ JSON.stringify(sent.slice(0, 3)));
+
+check('the drag replays in order',
+ sent.filter((s) => s.left)
+ .every((s, i, a) => i === 0 || s.x >= a[i - 1].x),
+ 'positions went backwards, so the replay ran out of order');
+
+check('no two consecutive states are identical',
+ sent.every((s, i) => i === 0
+ || JSON.stringify(s) !== JSON.stringify(sent[i - 1])),
+ 'the repeat suppression let a duplicate through');
+
+check('the release is reported with no button held',
+ sent.length > 0 && sent[sent.length - 1].left === false,
+ JSON.stringify(sent[sent.length - 1]));
+
+await browser('Target.closeTarget', { targetId });
+console.log(failures ? `\n${failures} failure(s)` : '\nall passed');
+process.exit(failures ? 1 : 0);
diff --git a/tests/browser/monitor-pointer.html b/tests/browser/monitor-pointer.html
new file mode 100644
index 0000000..f80d2dd
--- /dev/null
+++ b/tests/browser/monitor-pointer.html
@@ -0,0 +1,69 @@
+
+
+
pop-out monitor pointer, in a real browser
+
+
+drag across the blue area — real PointerEvents, real coalescing
+
+…
+
+
+
+
+
+
+
+
diff --git a/tests/browser/serve.mjs b/tests/browser/serve.mjs
new file mode 100644
index 0000000..e9f45d0
--- /dev/null
+++ b/tests/browser/serve.mjs
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+/*
+ * Serves the browser harnesses: the real static tree at the URLs the pages and
+ * any worker expect, the harness pages themselves, and -- where one is given
+ * -- a recording at /recording.guac.
+ *
+ * Over HTTP rather than from a file, because a worker cannot be started from a
+ * file:// page at all -- which is the whole thing being tested.
+ *
+ * Usage: node tests/browser/serve.mjs [recording.guac] [port]
+ */
+
+import { createServer } from 'node:http';
+import { createReadStream } from 'node:fs';
+import { stat } from 'node:fs/promises';
+import { dirname, resolve, join, extname, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+/* The recording is optional: the pointer harness needs no session, only the
+ * static tree and a page. */
+const recording = process.argv[2] ? resolve(process.argv[2]) : null;
+const port = parseInt(process.argv[3] || '8099', 10);
+
+const TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js' : 'text/javascript; charset=utf-8',
+ '.css' : 'text/css; charset=utf-8',
+ '.guac': 'text/plain; charset=utf-8'
+};
+
+createServer(async (req, res) => {
+
+ const url = new URL(req.url, 'http://localhost');
+ let file;
+
+ if (url.pathname === '/recording.guac' && recording)
+ file = recording;
+ else if (url.pathname === '/' || url.pathname.endsWith('.html'))
+ file = join(root, 'tests/browser',
+ url.pathname === '/' ? 'replay.html'
+ : url.pathname.replace(/^\//, ''));
+ else {
+ /* Only ever out of the static tree, and never up out of it. */
+ const rel = normalize(url.pathname).replace(/^(\.\.[/\\])+/, '');
+ file = join(root, 'static', rel);
+ if (!file.startsWith(join(root, 'static'))) {
+ res.writeHead(403).end('no');
+ return;
+ }
+ }
+
+ try {
+ const info = await stat(file);
+ res.writeHead(200, {
+ 'content-type' : TYPES[extname(file)] || 'application/octet-stream',
+ 'content-length' : info.size,
+ /* Nothing here is cached: the point is to serve what is on disk. */
+ 'cache-control' : 'no-store'
+ });
+ createReadStream(file).pipe(res);
+ } catch (e) {
+ res.writeHead(404).end('not found: ' + url.pathname);
+ }
+
+}).listen(port, '127.0.0.1', () => {
+ console.log(`replay harness on http://127.0.0.1:${port}/`);
+ console.log(` recording: ${recording || '(none given)'}`);
+});
diff --git a/tests/monitor-pointer.mjs b/tests/monitor-pointer.mjs
new file mode 100644
index 0000000..ac6bbf3
--- /dev/null
+++ b/tests/monitor-pointer.mjs
@@ -0,0 +1,281 @@
+#!/usr/bin/env node
+/*
+ * Pins pointer handling in the pop-out monitor windows.
+ *
+ * These windows cannot be opened without a live multi-monitor session, so
+ * until this logic was lifted out of client.html the only thing a test could
+ * do was match the source text. The two things most worth checking here are a
+ * coordinate mapping and a suppression rule, and both are the kind that look
+ * right in a diff and are off by a term.
+ *
+ * The mapping matters because it is not Guacamole.Mouse's -- these windows map
+ * the pointer themselves, from the canvas's live on-screen rect into this
+ * monitor's slice of the combined framebuffer, and a wrong offset there sends
+ * every click to the wrong head. The suppression matters because it is the one
+ * place on this branch where something that used to happen no longer does:
+ * this path used to send unconditionally and now drops a repeat of what it
+ * last sent.
+ *
+ * Usage: node tests/monitor-pointer.mjs
+ */
+
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+import assert from 'node:assert/strict';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+const sources = await Promise.all([
+ 'static/guac/Namespace.js',
+ 'static/guac/Event.js',
+ 'static/guac/Position.js',
+ 'static/guac/Mouse.js',
+ 'static/guac/MonitorPointer.js'
+].map((f) => readFile(join(root, f), 'utf8')));
+
+/**
+ * A monitor pointer on a fake canvas, with what it sent and the listeners it
+ * attached both reachable.
+ *
+ * The canvas sits at (100, 50) on screen and is 800x400 there, while the
+ * monitor it shows is 1920x1080 native at (2560, 0) in the combined
+ * framebuffer -- a second head, offset, and scaled down on screen. Deliberately
+ * none of those numbers are the same, so a mapping that drops a term or uses
+ * the wrong rect cannot land on the right answer by luck.
+ */
+function mounted({ rect = { left: 2560, top: 0, width: 1920, height: 1080 } } = {}) {
+
+ const sent = [];
+ const listeners = {};
+
+ const canvas = {
+ addEventListener : (name, fn) => { (listeners[name] ||= []).push(fn); },
+ getBoundingClientRect : () => ({ left: 100, top: 50, width: 800, height: 400 })
+ };
+
+ const sandbox = {
+ console : { log() {}, warn() {}, error() {} },
+ document : { body: {}, documentElement: {},
+ createElement: () => ({ style: { cursor: '' } }) },
+ Math, Object, Array,
+ PointerEvent : class { getCoalescedEvents() { return []; } }
+ };
+ sandbox.window = sandbox;
+ sandbox.window.localStorage = null;
+ sandbox.window.location = { search: '' };
+
+ vm.createContext(sandbox);
+ for (const src of sources)
+ vm.runInContext(src, sandbox);
+
+ let current = rect;
+ sandbox.Guacamole.MonitorPointer(canvas, () => current,
+ (state) => sent.push(state));
+
+ return {
+ sandbox, sent, listeners,
+ setRect : (r) => { current = r; },
+ fire : (name, ev) => (listeners[name] || []).forEach((fn) => fn(ev)),
+ has : (name) => !!listeners[name]
+ };
+}
+
+/** A DOM-ish event at a screen position. */
+function at(clientX, clientY, buttons = 0, extra = {}) {
+ return Object.assign({
+ clientX, clientY, buttons,
+ preventDefault() { this.defaultPrevented = true; }
+ }, extra);
+}
+
+/** A pointermove carrying the positions the browser merged into it. */
+function merged(positions, { buttons = 1, pointerType = 'mouse' } = {}) {
+ const events = positions.map(([x, y]) => at(x, y, buttons));
+ const last = events[events.length - 1];
+ return at(last.clientX, last.clientY, buttons, {
+ pointerType, getCoalescedEvents : () => events
+ });
+}
+
+let failures = 0;
+const test = (name, fn) => {
+ try { fn(); console.log(` ok ${name}`); }
+ catch (e) { failures++; console.log(` FAIL ${name}\n ${e.message}`); }
+};
+
+console.log('pop-out monitor pointer');
+
+/* ── The mapping ─────────────────────────────────────────────────────── */
+
+test('the centre of the canvas is the centre of that monitor', () => {
+ const m = mounted();
+ m.fire('mousemove', at(100 + 400, 50 + 200));
+ assert.deepEqual([m.sent[0].x, m.sent[0].y], [2560 + 960, 540]);
+});
+
+test('the origin of the canvas is the origin of that monitor', () => {
+ const m = mounted();
+ m.fire('mousemove', at(100, 50));
+ assert.deepEqual([m.sent[0].x, m.sent[0].y], [2560, 0],
+ "a second head's offset must be added, not assumed to be zero");
+});
+
+test('a position outside the canvas is clamped to it', () => {
+ const m = mounted();
+ m.fire('mousemove', at(-500, -500));
+ assert.deepEqual([m.sent[0].x, m.sent[0].y], [2560, 0]);
+ m.fire('mousemove', at(99999, 99999));
+ assert.deepEqual([m.sent[1].x, m.sent[1].y], [2560 + 1920, 1080]);
+});
+
+test('the layout is read per event, not captured when the window opened', () => {
+ const m = mounted();
+ m.fire('mousemove', at(500, 250));
+ m.setRect({ left: 0, top: 1080, width: 3840, height: 2160 });
+ m.fire('mousemove', at(500, 250));
+ assert.notDeepEqual([m.sent[0].x, m.sent[0].y], [m.sent[1].x, m.sent[1].y],
+ 'a monitor moved in the layout must move on the wire');
+});
+
+test('nothing is sent while the layout has no rectangle for this monitor', () => {
+ const m = mounted({ rect: null });
+ m.fire('mousemove', at(500, 250));
+ m.fire('wheel', at(500, 250, 0, { deltaY: -1 }));
+ assert.equal(m.sent.length, 0);
+});
+
+/* ── The buttons ─────────────────────────────────────────────────────── */
+
+test('the button mask is decoded, not passed through', () => {
+ const m = mounted();
+ /* DOM buttons: 1 left, 2 RIGHT, 4 middle -- which is not the order the
+ * Guacamole state uses, and transposing the middle and right is the
+ * classic way to get this wrong. */
+ m.fire('mousedown', at(500, 250, 1));
+ m.fire('mousedown', at(500, 250, 2));
+ m.fire('mousedown', at(500, 250, 4));
+ assert.deepEqual(m.sent.map(s => [s.left, s.middle, s.right]),
+ [[true, false, false], [false, false, true], [false, true, false]]);
+});
+
+/* ── The suppression ─────────────────────────────────────────────────── */
+
+test('the same state is not sent twice', () => {
+ const m = mounted();
+ m.fire('mousemove', at(500, 250));
+ m.fire('mousemove', at(500, 250));
+ m.fire('mousemove', at(500, 250));
+ assert.equal(m.sent.length, 1);
+});
+
+test('a press and a release at one position are not repeats of each other', () => {
+ const m = mounted();
+ m.fire('mousedown', at(500, 250, 1));
+ m.fire('mousemove', at(500, 250, 1));
+ m.fire('mouseup', at(500, 250, 0));
+ assert.deepEqual(m.sent.map(s => s.left), [true, false],
+ 'the move between them is the repeat; the release is not');
+});
+
+test('the wheel clears the suppression it sends behind the back of', () => {
+ const m = mounted();
+ m.fire('mousemove', at(500, 250));
+ const before = m.sent.length;
+
+ m.fire('wheel', at(500, 250, 0, { deltaY: -1 }));
+ assert.equal(m.sent.length, before + 2, 'a wheel click is press then release');
+ assert.equal(m.sent[before].up, true);
+ assert.equal(m.sent[before + 1].up, false);
+
+ /* Without the reset this position is still what pointer() thinks it last
+ * sent, and the pointer would go silent at the spot the user scrolled. */
+ m.fire('mousemove', at(500, 250));
+ assert.equal(m.sent.length, before + 3,
+ 'the position the wheel moved away from must be sendable again');
+});
+
+test('the wheel direction follows the sign of the delta', () => {
+ const m = mounted();
+ m.fire('wheel', at(500, 250, 0, { deltaY: -1 }));
+ m.fire('wheel', at(500, 250, 0, { deltaY: 1 }));
+ assert.deepEqual(m.sent.map(s => [s.up, s.down]),
+ [[true, false], [false, false], [false, true], [false, false]]);
+});
+
+/* ── The coalesced replay ────────────────────────────────────────────── */
+
+test('a drag replays every position the browser merged away', () => {
+ const m = mounted();
+ m.fire('pointermove', merged([[200, 100], [300, 150], [400, 200], [500, 250]]));
+ assert.equal(m.sent.length, 4);
+ assert.deepEqual([m.sent[0].x, m.sent[0].y], [2560 + 240, 135]);
+ assert.deepEqual([m.sent[3].x, m.sent[3].y], [2560 + 960, 540]);
+});
+
+test('and the mousemove that follows it is not sent again', () => {
+ const m = mounted();
+ m.fire('pointermove', merged([[200, 100], [500, 250]]));
+ m.fire('mousemove', at(500, 250, 1));
+ assert.equal(m.sent.length, 2);
+});
+
+test('a hover replays nothing', () => {
+ const m = mounted();
+ m.fire('pointermove', merged([[200, 100], [500, 250]], { buttons: 0 }));
+ assert.equal(m.sent.length, 0);
+});
+
+test('touch is left to the handling it already has', () => {
+ const m = mounted();
+ m.fire('pointermove', merged([[200, 100], [500, 250]], { pointerType: 'touch' }));
+ assert.equal(m.sent.length, 0);
+});
+
+test('a move that merged nothing is left to the mousemove listener', () => {
+ const m = mounted();
+ m.fire('pointermove', merged([[500, 250]]));
+ assert.equal(m.sent.length, 0);
+});
+
+test('a flood is bounded by the same rule the main display uses', () => {
+ const m = mounted();
+ const max = m.sandbox.Guacamole.Mouse.COALESCED_MAX;
+ const flood = [];
+ for (let i = 1; i <= max * 8; i++) flood.push([100 + i, 50 + i]);
+ m.fire('pointermove', merged(flood));
+ assert.equal(m.sent.length, max);
+});
+
+test('the override switches the replay off and leaves the rest alone', () => {
+ const m = mounted();
+ m.sandbox.Guacamole.Mouse.coalescedMovement = false;
+ m.fire('pointermove', merged([[200, 100], [500, 250]]));
+ assert.equal(m.sent.length, 0, 'no replay');
+ m.fire('mousemove', at(500, 250, 1));
+ assert.equal(m.sent.length, 1, 'and the ordinary path still works');
+});
+
+/* ── The rest of the contract ────────────────────────────────────────── */
+
+test('the context menu is suppressed, or right-click never reaches the host', () => {
+ const m = mounted();
+ assert.ok(m.has('contextmenu'));
+ const ev = at(500, 250, 2);
+ m.fire('contextmenu', ev);
+ assert.ok(ev.defaultPrevented);
+});
+
+test('the browser is stopped from acting on what it is given', () => {
+ const m = mounted();
+ const move = at(500, 250, 1);
+ m.fire('mousemove', move);
+ assert.ok(move.defaultPrevented, 'or the popup selects and drags its own canvas');
+ const wheel = at(500, 250, 0, { deltaY: -1 });
+ m.fire('wheel', wheel);
+ assert.ok(wheel.defaultPrevented, 'or the popup scrolls itself');
+});
+
+console.log(failures ? `\n${failures} failure(s)` : '\nall passed');
+process.exit(failures ? 1 : 0);
diff --git a/tests/mouse-coalesced.mjs b/tests/mouse-coalesced.mjs
new file mode 100644
index 0000000..ad72112
--- /dev/null
+++ b/tests/mouse-coalesced.mjs
@@ -0,0 +1,251 @@
+#!/usr/bin/env node
+/*
+ * Pins the recovery of coalesced pointer movement.
+ *
+ * This path only runs when the browser has already discarded something: moves
+ * merged away behind a busy main thread. That makes it close to untestable by
+ * using the application -- the case it exists for is the one you cannot
+ * reliably produce, and when it is broken the symptom is a drag that feels
+ * slightly worse, which is what it felt like before. So the behaviour is
+ * pinned here instead.
+ *
+ * Three things matter and none of them is obvious from reading it. The
+ * dispatched position must be replayed along with the merged ones, or a
+ * browser that fires no compatibility mousemove leaves the pointer one event
+ * behind for the whole drag. Nothing may be replayed unless a button is held,
+ * or an idle pointer multiplies a session's upstream mouse traffic for
+ * movement nobody can perceive. And the existing mousemove listener must keep
+ * working exactly as it did, because touch reaches it as compatibility events
+ * and a counter it shares is what swallows them.
+ *
+ * Usage: node tests/mouse-coalesced.mjs
+ */
+
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+import assert from 'node:assert/strict';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+const sources = await Promise.all([
+ 'static/guac/Namespace.js',
+ 'static/guac/Event.js',
+ 'static/guac/Position.js',
+ 'static/guac/Mouse.js'
+].map((f) => readFile(join(root, f), 'utf8')));
+
+const monitorPointer = await readFile(
+ join(root, 'static/guac/MonitorPointer.js'), 'utf8');
+
+/**
+ * A Guacamole.Mouse on a fake element, with the listeners it attached and the
+ * mousemove events it emitted both reachable.
+ */
+function mounted({ globals = {}, pointerEvents = true } = {}) {
+
+ const listeners = {};
+ const element = {
+ offsetLeft : 0,
+ offsetTop : 0,
+ offsetParent : null,
+ addEventListener : (name, fn) => { (listeners[name] ||= []).push(fn); }
+ };
+
+ const sandbox = {
+ console : { log() {}, warn() {}, error() {} },
+ document : {
+ body : {},
+ documentElement : {},
+ /* Only the CSS3 cursor probe in the constructor wants this. */
+ createElement : () => ({ style: { cursor: '' } })
+ },
+ setTimeout, clearTimeout, Math, Array, Object
+ };
+ sandbox.window = Object.assign(sandbox, globals);
+ if (pointerEvents)
+ sandbox.PointerEvent = class { getCoalescedEvents() { return []; } };
+
+ vm.createContext(sandbox);
+ for (const src of sources)
+ vm.runInContext(src, sandbox);
+
+ const mouse = new sandbox.Guacamole.Mouse(element);
+ const moves = [];
+ mouse.onEach(['mousemove'], (e) => moves.push([e.state.x, e.state.y]));
+
+ return {
+ sandbox, mouse, moves, listeners,
+ fire : (name, event) => (listeners[name] || []).forEach((fn) => fn(event)),
+ has : (name) => !!listeners[name]
+ };
+}
+
+/** A DOM event, near enough: the suppression path cancels what it swallows. */
+function domEvent(props) {
+ return Object.assign({
+ stopPropagation() {},
+ preventDefault() {},
+ returnValue : true
+ }, props);
+}
+
+/** A pointermove carrying the positions the browser merged into it. */
+function pointerMove(positions, { buttons = 1, pointerType = 'mouse' } = {}) {
+ const merged = positions.map(([x, y]) => ({ clientX: x, clientY: y }));
+ const last = merged[merged.length - 1];
+ return domEvent({
+ pointerType, buttons,
+ clientX : last.clientX,
+ clientY : last.clientY,
+ getCoalescedEvents : () => merged
+ });
+}
+
+let failures = 0;
+const test = (name, fn) => {
+ try { fn(); console.log(` ok ${name}`); }
+ catch (e) { failures++; console.log(` FAIL ${name}\n ${e.message}`); }
+};
+
+console.log('coalesced pointer movement');
+
+test('a drag replays every position the browser merged away', () => {
+ const m = mounted();
+ m.fire('pointermove', pointerMove([[10, 10], [20, 22], [30, 35], [40, 50]]));
+ assert.deepEqual(m.moves.map(p => [...p]),
+ [[10, 10], [20, 22], [30, 35], [40, 50]]);
+});
+
+test('the dispatched position is replayed too, not assumed to follow', () => {
+ /* A browser that fires no compatibility mousemove would otherwise leave
+ * the pointer one event behind for the length of the drag. */
+ const m = mounted();
+ m.fire('pointermove', pointerMove([[1, 1], [9, 9]]));
+ assert.deepEqual([...m.moves[m.moves.length - 1]], [9, 9]);
+});
+
+test('and the mousemove that follows it is not sent twice', () => {
+ const m = mounted();
+ const move = pointerMove([[1, 1], [9, 9]]);
+ m.fire('pointermove', move);
+ m.fire('mousemove', domEvent({ clientX: 9, clientY: 9 }));
+ assert.equal(m.moves.length, 2, 'the repeat is dropped as unchanged');
+});
+
+test('a hover replays nothing', () => {
+ const m = mounted();
+ m.fire('pointermove', pointerMove([[1, 1], [5, 5], [9, 9]], { buttons: 0 }));
+ assert.equal(m.moves.length, 0);
+});
+
+test('touch is left to the handling it already has', () => {
+ const m = mounted();
+ m.fire('pointermove', pointerMove([[1, 1], [9, 9]], { pointerType: 'touch' }));
+ assert.equal(m.moves.length, 0);
+});
+
+test('a move that merged nothing is left alone', () => {
+ const m = mounted();
+ m.fire('pointermove', pointerMove([[7, 7]]));
+ assert.equal(m.moves.length, 0, 'the mousemove listener delivers this one');
+});
+
+test('the mousemove listener still delivers on its own', () => {
+ const m = mounted();
+ m.fire('mousemove', domEvent({ clientX: 3, clientY: 4 }));
+ assert.deepEqual([...m.moves[0]], [3, 4]);
+});
+
+test('synthetic events after touch are still swallowed, and still counted', () => {
+ const m = mounted();
+
+ m.fire('touchstart', domEvent({}));
+
+ /* Whatever the counter is set to, a real pointermove must not sneak past
+ * it -- and must not decrement it either, or the listener that is counting
+ * is left counting the wrong thing. */
+ m.fire('pointermove', pointerMove([[1, 1], [9, 9]]));
+ assert.equal(m.moves.length, 0, 'a drag must not slip past the counter');
+
+ for (let i = 0; i < m.mouse.touchMouseThreshold; i++)
+ m.fire('mousemove', domEvent({ clientX: i, clientY: i }));
+ assert.equal(m.moves.length, 0, 'the synthetic events are swallowed');
+
+ m.fire('mousemove', domEvent({ clientX: 50, clientY: 60 }));
+ assert.deepEqual([...m.moves[0]], [50, 60], 'and normal service resumes');
+});
+
+test('a flood is sampled to a bounded number, keeping the last', () => {
+ const m = mounted();
+ const max = m.sandbox.Guacamole.Mouse.COALESCED_MAX;
+ /* From 1, not 0: a mouse starts at (0,0) and move() drops a position it
+ * is already at, which would cost this test its first sample for reasons
+ * that have nothing to do with sampling. */
+ const flood = [];
+ for (let i = 1; i <= max * 8; i++)
+ flood.push([i, i * 2]);
+
+ m.fire('pointermove', pointerMove(flood));
+
+ assert.equal(m.moves.length, max, `expected ${max}, got ${m.moves.length}`);
+ assert.deepEqual([...m.moves[m.moves.length - 1]],
+ flood[flood.length - 1], 'the last position is where the pointer is');
+ assert.deepEqual([...m.moves[0]], flood[0], 'and the first is where it was');
+
+ /* Monotonic, so the path still reads as a path. */
+ for (let i = 1; i < m.moves.length; i++)
+ assert.ok(m.moves[i][0] > m.moves[i - 1][0], 'sampled out of order');
+});
+
+test('the override switches it off, and the mousemove path is untouched', () => {
+ const m = mounted({ globals: { __mouseCoalesced: false } });
+ assert.equal(m.has('pointermove'), false, 'no listener is attached at all');
+ m.fire('mousemove', domEvent({ clientX: 3, clientY: 4 }));
+ assert.deepEqual([...m.moves[0]], [3, 4]);
+});
+
+test('a browser without pointer events keeps the behaviour it had', () => {
+ const m = mounted({ pointerEvents: false });
+ assert.equal(m.has('pointermove'), false);
+ m.fire('mousemove', domEvent({ clientX: 3, clientY: 4 }));
+ assert.deepEqual([...m.moves[0]], [3, 4]);
+});
+
+/* ── The pop-out monitor windows ─────────────────────────────────────── */
+
+/*
+ * Those windows bypass Guacamole.Mouse entirely -- it does not track the X
+ * axis correctly in a popup -- and map the pointer themselves. That code is
+ * exercised directly by tests/monitor-pointer.mjs, which is possible only
+ * because it lives in Guacamole.MonitorPointer rather than inline in the page.
+ * What is left here is the join between them: the popup path must be bounded
+ * by the same rule and switched by the same override as the main display, or
+ * the two drift and only one of them answers the setting.
+ */
+
+const page = await readFile(join(root, 'static/client.html'), 'utf8');
+
+test('the page hands its pop-out windows to the shared implementation', () => {
+ assert.ok(page.includes('Guacamole.MonitorPointer('),
+ 'client.html must use the extracted pointer handling');
+ assert.ok(page.includes('src="/guac/MonitorPointer.js"'),
+ 'and must actually load it, or the call is a ReferenceError');
+});
+
+test('the popup path is gated and bounded by the main display\'s own rules', () => {
+ const sandbox = { Guacamole: {}, console: { log() {}, warn() {} } };
+ vm.createContext(sandbox);
+ vm.runInContext(sources[3], sandbox);
+ vm.runInContext(monitorPointer, sandbox);
+
+ assert.equal(typeof sandbox.Guacamole.MonitorPointer, 'function');
+ assert.ok(monitorPointer.includes('Guacamole.Mouse.coalescedMovement'),
+ 'the same override must reach both, or one ignores the setting');
+ assert.ok(monitorPointer.includes('Guacamole.Mouse.sampleCoalesced'),
+ 'and the same cap, or the two drift apart');
+});
+
+console.log(failures ? `\n${failures} failure(s)` : '\nall passed');
+process.exit(failures ? 1 : 0);