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);