diff --git a/app/images/pointer.svg b/app/images/pointer.svg new file mode 100644 index 000000000..dd394008e --- /dev/null +++ b/app/images/pointer.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/app/ui.js b/app/ui.js index 8e2e78ff9..29335572e 100644 --- a/app/ui.js +++ b/app/ui.js @@ -224,6 +224,10 @@ const UI = { document.getElementById("noVNC_view_drag_button") .addEventListener('click', UI.toggleViewDrag); + document + .getElementById("noVNC_pointer_lock_button") + .addEventListener("click", UI.requestPointerLock); + document.getElementById("noVNC_control_bar_handle") .addEventListener('mousedown', UI.controlbarHandleMouseDown); document.getElementById("noVNC_control_bar_handle") @@ -441,6 +445,7 @@ const UI = { UI.updatePowerButton(); UI.keepControlbar(); } + UI.updatePointerLockButton(); // State change closes dialogs as they may not be relevant // anymore @@ -1036,6 +1041,7 @@ const UI = { UI.rfb.addEventListener("clipboard", UI.clipboardReceive); UI.rfb.addEventListener("bell", UI.bell); UI.rfb.addEventListener("desktopname", UI.updateDesktopName); + UI.rfb.addEventListener("pointerlock", UI.pointerLockChanged); UI.rfb.clipViewport = UI.getSetting('view_clip'); UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale'; UI.rfb.resizeSession = UI.getSetting('resize') === 'remote'; @@ -1297,6 +1303,33 @@ const UI = { /* ------^------- * /VIEW CLIPPING * ============== + * POINTER LOCK + * ------v------*/ + + updatePointerLockButton() { + // Only show the button if the pointer lock API is properly supported + if ( + UI.connected && + (document.pointerLockElement !== undefined || + document.mozPointerLockElement !== undefined) + ) { + document + .getElementById("noVNC_pointer_lock_button") + .classList.remove("noVNC_hidden"); + } else { + document + .getElementById("noVNC_pointer_lock_button") + .classList.add("noVNC_hidden"); + } + }, + + requestPointerLock() { + UI.rfb.requestPointerLock(); + }, + +/* ------^------- + * /POINTER LOCK + * ============== * VIEWDRAG * ------v------*/ @@ -1662,6 +1695,18 @@ const UI = { document.title = e.detail.name + " - " + PAGE_TITLE; }, + pointerLockChanged(e) { + if (e.detail.pointerlock) { + document + .getElementById("noVNC_pointer_lock_button") + .classList.add("noVNC_selected"); + } else { + document + .getElementById("noVNC_pointer_lock_button") + .classList.remove("noVNC_selected"); + } + }, + bell(e) { if (WebUtil.getConfigVar('bell', 'on') === 'on') { const promise = document.getElementById('noVNC_bell').play(); diff --git a/core/encodings.js b/core/encodings.js index 51c099291..584bd01a6 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -28,6 +28,7 @@ export const encodings = { pseudoEncodingCompressLevel9: -247, pseudoEncodingCompressLevel0: -256, pseudoEncodingVMwareCursor: 0x574d5664, + pseudoEncodingVMwareCursorPosition: 0x574d5666, pseudoEncodingExtendedClipboard: 0xc0a1e5ce }; diff --git a/core/rfb.js b/core/rfb.js index 26cdfcd0b..e0f793a07 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -151,6 +151,7 @@ export default class RFB extends EventTargetMixin { this._mousePos = {}; this._mouseButtonMask = 0; this._mouseLastMoveTime = 0; + this._pointerLock = false; this._viewportDragging = false; this._viewportDragPos = {}; this._viewportHasMoved = false; @@ -168,6 +169,7 @@ export default class RFB extends EventTargetMixin { focusCanvas: this._focusCanvas.bind(this), windowResize: this._windowResize.bind(this), handleMouse: this._handleMouse.bind(this), + handlePointerLockChange: this._handlePointerLockChange.bind(this), handleWheel: this._handleWheel.bind(this), handleGesture: this._handleGesture.bind(this), }; @@ -477,6 +479,14 @@ export default class RFB extends EventTargetMixin { this._canvas.blur(); } + requestPointerLock() { + if (this._canvas.requestPointerLock) { + this._canvas.requestPointerLock(); + } else if (this._canvas.mozRequestPointerLock) { + this._canvas.mozRequestPointerLock(); + } + } + clipboardPasteFrom(text) { if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; } @@ -539,6 +549,8 @@ export default class RFB extends EventTargetMixin { // preventDefault() on mousedown doesn't stop this event for some // reason so we have to explicitly block it this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse); + // This needs to be installed in document instead of the canvas. + document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange); // Wheel events this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel); @@ -563,6 +575,7 @@ export default class RFB extends EventTargetMixin { this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse); this._canvas.removeEventListener('click', this._eventHandlers.handleMouse); this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse); + document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange); this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas); this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas); window.removeEventListener('resize', this._eventHandlers.windowResize); @@ -885,8 +898,26 @@ export default class RFB extends EventTargetMixin { return; } - let pos = clientToElement(ev.clientX, ev.clientY, + let pos; + if (this._pointerLock) { + pos = { + x: this._mousePos.x + ev.movementX, + y: this._mousePos.y + ev.movementY, + }; + if (pos.x < 0) { + pos.x = 0; + } else if (pos.x > this._fbWidth) { + pos.x = this._fbWidth; + } + if (pos.y < 0) { + pos.y = 0; + } else if (pos.y > this._fbHeight) { + pos.y = this._fbHeight; + } + } else { + pos = clientToElement(ev.clientX, ev.clientY, this._canvas); + } switch (ev.type) { case 'mousedown': @@ -987,6 +1018,20 @@ export default class RFB extends EventTargetMixin { this._mouseLastMoveTime = Date.now(); } + _handlePointerLockChange() { + if ( + document.pointerLockElement === this._canvas || + document.mozPointerLockElement === this._canvas + ) { + this._pointerLock = true; + } else { + this._pointerLock = false; + } + this.dispatchEvent(new CustomEvent( + "pointerlock", + { detail: { pointerlock: this._pointerLock }, })); + } + _sendMouse(x, y, mask) { if (this._rfbConnectionState !== 'connected') { return; } if (this._viewOnly) { return; } // View only, skip mouse events @@ -1767,6 +1812,8 @@ export default class RFB extends EventTargetMixin { encs.push(encodings.pseudoEncodingCursor); } + encs.push(encodings.pseudoEncodingVMwareCursorPosition); + RFB.messages.clientEncodings(this._sock, encs); } @@ -2165,6 +2212,9 @@ export default class RFB extends EventTargetMixin { case encodings.pseudoEncodingVMwareCursor: return this._handleVMwareCursor(); + case encodings.pseudoEncodingVMwareCursorPosition: + return this._handleVMwareCursorPosition(); + case encodings.pseudoEncodingCursor: return this._handleCursor(); @@ -2303,6 +2353,19 @@ export default class RFB extends EventTargetMixin { return true; } + _handleVMwareCursorPosition() { + const x = this._FBU.x; + const y = this._FBU.y; + + if (this._pointerLock) { + // Only attempt to match the server's pointer position if we are in + // pointer lock mode. + this._mousePos = { x: x, y: y }; + } + + return true; + } + _handleCursor() { const hotx = this._FBU.x; // hotspot-x const hoty = this._FBU.y; // hotspot-y diff --git a/tests/test.rfb.js b/tests/test.rfb.js index d5a9adc81..3807a2852 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -2514,6 +2514,15 @@ describe('Remote Frame Buffer Protocol Client', function () { client._canvas.dispatchEvent(ev); } + function sendMouseMovementEvent(dx, dy) { + let ev; + + ev = new MouseEvent('mousemove', + { 'movementX': dx, + 'movementY': dy }); + client._canvas.dispatchEvent(ev); + } + function sendMouseButtonEvent(x, y, down, button) { let pos = elementToClient(x, y); let ev; @@ -2627,6 +2636,52 @@ describe('Remote Frame Buffer Protocol Client', function () { 50, 70, 0x0); }); + it('should ignore remote cursor position updates', function() { + // Simple VMware Cursor Position FBU message with pointer coordinates + // (0xDEA3, 0xBEE5). + const incoming = [ 0x00, 0x00, 0x00, 0x01, 0xDE, 0xA3, 0xBE, 0xE5, + 0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ]; + client._resize(0xFFFF, 0xFFFF); + + const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition'); + client._sock._websocket._receiveData(new Uint8Array(incoming)); + expect(cursorSpy).to.have.been.calledOnceWith(); + cursorSpy.restore(); + + sendMouseMoveEvent(10, 10); + clock.tick(100); + expect(pointerEvent).to.have.been.calledOnceWith(client._sock, + 10, 10, 0x0); + }); + + it('should handle remote mouse position updates in pointer lock mode', function() { + // Simple VMware Cursor Position FBU message with pointer coordinates + // (0xDEA3, 0xBEE5). + const incoming = [ 0x00, 0x00, 0x00, 0x01, 0xDE, 0xA3, 0xBE, 0xE5, + 0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ]; + client._resize(0xFFFF, 0xFFFF); + + const spy = sinon.spy(); + client.addEventListener("pointerlock", spy); + let stub = sinon.stub(document, 'pointerLockElement'); + stub.get(function () { return client._canvas; }); + client._handlePointerLockChange(); + stub.restore(); + client._sock._websocket._receiveData(new Uint8Array([0x02, 0x02])); + expect(spy).to.have.been.calledOnce; + expect(spy.args[0][0].detail.pointerlock).to.be.true; + + const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition'); + client._sock._websocket._receiveData(new Uint8Array(incoming)); + expect(cursorSpy).to.have.been.calledOnceWith(); + cursorSpy.restore(); + + sendMouseMovementEvent(10, 10); + clock.tick(100); + expect(pointerEvent).to.have.been.calledOnceWith(client._sock, + 0xDEAD, 0xBEEF, 0x0); + }); + describe('Event Aggregation', function () { it('should send a single pointer event on mouse movement', function () { sendMouseMoveEvent(50, 70); diff --git a/vnc.html b/vnc.html index 7870b7c30..5e7fcef2d 100644 --- a/vnc.html +++ b/vnc.html @@ -79,6 +79,11 @@

no
VNC

id="noVNC_view_drag_button" class="noVNC_button noVNC_hidden" title="Move/Drag Viewport"> + + +