Skip to content

Commit ec708a1

Browse files
committed
Support grabbing the pointer with the Pointer Lock API
This change adds the following: a) A new button on the UI to enter full pointer lock mode, which invokes the Pointer Lock API[1] on the canvas, which hides the cursor and makes mouse events provide relative motion from the previous event (through `movementX` and `movementY`). These can be added to the previously-known mouse position to convert it back to an absolute position. b) Adds support for the VMware Cursor Position pseudo-encoding[2], which servers can use when they make cursor position changes themselves. This is done by some APIs like SDL, when they detect that the client does not support relative mouse movement[3] and then "warp"[4] the cursor to the center of the window, to calculate the relative mouse motion themselves. c) When the canvas is in pointer lock mode and the cursor is not being locally displayed, it updates the cursor position with the information that the server sends, since the actual position of the cursor does not matter locally anymore, since it's not visible. d) Adds some tests for the above. You can try this out end-to-end with TigerVNC with TigerVNC/tigervnc#1198 applied! Fixes: novnc#1493 under some circumstances (at least all SDL games would now work). 1: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API 2: https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst#vmware-cursor-position-pseudo-encoding 3: https://hg.libsdl.org/SDL/file/28e3b60e2131/src/events/SDL_mouse.c#l804 4: https://tronche.com/gui/x/xlib/input/XWarpPointer.html
1 parent 5a0cceb commit ec708a1

File tree

6 files changed

+248
-1
lines changed

6 files changed

+248
-1
lines changed

app/images/pointer.svg

+78
Loading

app/ui.js

+45
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ const UI = {
224224
document.getElementById("noVNC_view_drag_button")
225225
.addEventListener('click', UI.toggleViewDrag);
226226

227+
document
228+
.getElementById("noVNC_pointer_lock_button")
229+
.addEventListener("click", UI.requestPointerLock);
230+
227231
document.getElementById("noVNC_control_bar_handle")
228232
.addEventListener('mousedown', UI.controlbarHandleMouseDown);
229233
document.getElementById("noVNC_control_bar_handle")
@@ -441,6 +445,7 @@ const UI = {
441445
UI.updatePowerButton();
442446
UI.keepControlbar();
443447
}
448+
UI.updatePointerLockButton();
444449

445450
// State change closes dialogs as they may not be relevant
446451
// anymore
@@ -1036,6 +1041,7 @@ const UI = {
10361041
UI.rfb.addEventListener("clipboard", UI.clipboardReceive);
10371042
UI.rfb.addEventListener("bell", UI.bell);
10381043
UI.rfb.addEventListener("desktopname", UI.updateDesktopName);
1044+
UI.rfb.addEventListener("pointerlock", UI.pointerLockChanged);
10391045
UI.rfb.clipViewport = UI.getSetting('view_clip');
10401046
UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
10411047
UI.rfb.resizeSession = UI.getSetting('resize') === 'remote';
@@ -1297,6 +1303,33 @@ const UI = {
12971303
/* ------^-------
12981304
* /VIEW CLIPPING
12991305
* ==============
1306+
* POINTER LOCK
1307+
* ------v------*/
1308+
1309+
updatePointerLockButton() {
1310+
// Only show the button if the pointer lock API is properly supported
1311+
if (
1312+
UI.connected &&
1313+
(document.pointerLockElement !== undefined ||
1314+
document.mozPointerLockElement !== undefined)
1315+
) {
1316+
document
1317+
.getElementById("noVNC_pointer_lock_button")
1318+
.classList.remove("noVNC_hidden");
1319+
} else {
1320+
document
1321+
.getElementById("noVNC_pointer_lock_button")
1322+
.classList.add("noVNC_hidden");
1323+
}
1324+
},
1325+
1326+
requestPointerLock() {
1327+
UI.rfb.requestPointerLock();
1328+
},
1329+
1330+
/* ------^-------
1331+
* /POINTER LOCK
1332+
* ==============
13001333
* VIEWDRAG
13011334
* ------v------*/
13021335

@@ -1662,6 +1695,18 @@ const UI = {
16621695
document.title = e.detail.name + " - " + PAGE_TITLE;
16631696
},
16641697

1698+
pointerLockChanged(e) {
1699+
if (e.detail.pointerlock) {
1700+
document
1701+
.getElementById("noVNC_pointer_lock_button")
1702+
.classList.add("noVNC_selected");
1703+
} else {
1704+
document
1705+
.getElementById("noVNC_pointer_lock_button")
1706+
.classList.remove("noVNC_selected");
1707+
}
1708+
},
1709+
16651710
bell(e) {
16661711
if (WebUtil.getConfigVar('bell', 'on') === 'on') {
16671712
const promise = document.getElementById('noVNC_bell').play();

core/encodings.js

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const encodings = {
2828
pseudoEncodingCompressLevel9: -247,
2929
pseudoEncodingCompressLevel0: -256,
3030
pseudoEncodingVMwareCursor: 0x574d5664,
31+
pseudoEncodingVMwareCursorPosition: 0x574d5666,
3132
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
3233
};
3334

core/rfb.js

+64-1
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export default class RFB extends EventTargetMixin {
151151
this._mousePos = {};
152152
this._mouseButtonMask = 0;
153153
this._mouseLastMoveTime = 0;
154+
this._pointerLock = false;
154155
this._viewportDragging = false;
155156
this._viewportDragPos = {};
156157
this._viewportHasMoved = false;
@@ -168,6 +169,7 @@ export default class RFB extends EventTargetMixin {
168169
focusCanvas: this._focusCanvas.bind(this),
169170
windowResize: this._windowResize.bind(this),
170171
handleMouse: this._handleMouse.bind(this),
172+
handlePointerLockChange: this._handlePointerLockChange.bind(this),
171173
handleWheel: this._handleWheel.bind(this),
172174
handleGesture: this._handleGesture.bind(this),
173175
};
@@ -477,6 +479,14 @@ export default class RFB extends EventTargetMixin {
477479
this._canvas.blur();
478480
}
479481

482+
requestPointerLock() {
483+
if (this._canvas.requestPointerLock) {
484+
this._canvas.requestPointerLock();
485+
} else if (this._canvas.mozRequestPointerLock) {
486+
this._canvas.mozRequestPointerLock();
487+
}
488+
}
489+
480490
clipboardPasteFrom(text) {
481491
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
482492

@@ -539,6 +549,8 @@ export default class RFB extends EventTargetMixin {
539549
// preventDefault() on mousedown doesn't stop this event for some
540550
// reason so we have to explicitly block it
541551
this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
552+
// This needs to be installed in document instead of the canvas.
553+
document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
542554

543555
// Wheel events
544556
this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
@@ -563,6 +575,7 @@ export default class RFB extends EventTargetMixin {
563575
this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse);
564576
this._canvas.removeEventListener('click', this._eventHandlers.handleMouse);
565577
this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse);
578+
document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
566579
this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
567580
this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
568581
window.removeEventListener('resize', this._eventHandlers.windowResize);
@@ -885,8 +898,26 @@ export default class RFB extends EventTargetMixin {
885898
return;
886899
}
887900

888-
let pos = clientToElement(ev.clientX, ev.clientY,
901+
let pos;
902+
if (this._pointerLock) {
903+
pos = {
904+
x: this._mousePos.x + ev.movementX,
905+
y: this._mousePos.y + ev.movementY,
906+
};
907+
if (pos.x < 0) {
908+
pos.x = 0;
909+
} else if (pos.x > this._fbWidth) {
910+
pos.x = this._fbWidth;
911+
}
912+
if (pos.y < 0) {
913+
pos.y = 0;
914+
} else if (pos.y > this._fbHeight) {
915+
pos.y = this._fbHeight;
916+
}
917+
} else {
918+
pos = clientToElement(ev.clientX, ev.clientY,
889919
this._canvas);
920+
}
890921

891922
switch (ev.type) {
892923
case 'mousedown':
@@ -987,6 +1018,20 @@ export default class RFB extends EventTargetMixin {
9871018
this._mouseLastMoveTime = Date.now();
9881019
}
9891020

1021+
_handlePointerLockChange() {
1022+
if (
1023+
document.pointerLockElement === this._canvas ||
1024+
document.mozPointerLockElement === this._canvas
1025+
) {
1026+
this._pointerLock = true;
1027+
} else {
1028+
this._pointerLock = false;
1029+
}
1030+
this.dispatchEvent(new CustomEvent(
1031+
"pointerlock",
1032+
{ detail: { pointerlock: this._pointerLock }, }));
1033+
}
1034+
9901035
_sendMouse(x, y, mask) {
9911036
if (this._rfbConnectionState !== 'connected') { return; }
9921037
if (this._viewOnly) { return; } // View only, skip mouse events
@@ -1767,6 +1812,8 @@ export default class RFB extends EventTargetMixin {
17671812
encs.push(encodings.pseudoEncodingCursor);
17681813
}
17691814

1815+
encs.push(encodings.pseudoEncodingVMwareCursorPosition);
1816+
17701817
RFB.messages.clientEncodings(this._sock, encs);
17711818
}
17721819

@@ -2165,6 +2212,9 @@ export default class RFB extends EventTargetMixin {
21652212
case encodings.pseudoEncodingVMwareCursor:
21662213
return this._handleVMwareCursor();
21672214

2215+
case encodings.pseudoEncodingVMwareCursorPosition:
2216+
return this._handleVMwareCursorPosition();
2217+
21682218
case encodings.pseudoEncodingCursor:
21692219
return this._handleCursor();
21702220

@@ -2303,6 +2353,19 @@ export default class RFB extends EventTargetMixin {
23032353
return true;
23042354
}
23052355

2356+
_handleVMwareCursorPosition() {
2357+
const x = this._FBU.x;
2358+
const y = this._FBU.y;
2359+
2360+
if (this._pointerLock) {
2361+
// Only attempt to match the server's pointer position if we are in
2362+
// pointer lock mode.
2363+
this._mousePos = { x: x, y: y };
2364+
}
2365+
2366+
return true;
2367+
}
2368+
23062369
_handleCursor() {
23072370
const hotx = this._FBU.x; // hotspot-x
23082371
const hoty = this._FBU.y; // hotspot-y

tests/test.rfb.js

+55
Original file line numberDiff line numberDiff line change
@@ -2514,6 +2514,15 @@ describe('Remote Frame Buffer Protocol Client', function () {
25142514
client._canvas.dispatchEvent(ev);
25152515
}
25162516

2517+
function sendMouseMovementEvent(dx, dy) {
2518+
let ev;
2519+
2520+
ev = new MouseEvent('mousemove',
2521+
{ 'movementX': dx,
2522+
'movementY': dy });
2523+
client._canvas.dispatchEvent(ev);
2524+
}
2525+
25172526
function sendMouseButtonEvent(x, y, down, button) {
25182527
let pos = elementToClient(x, y);
25192528
let ev;
@@ -2627,6 +2636,52 @@ describe('Remote Frame Buffer Protocol Client', function () {
26272636
50, 70, 0x0);
26282637
});
26292638

2639+
it('should ignore remote cursor position updates', function() {
2640+
// Simple VMware Cursor Position FBU message with pointer coordinates
2641+
// (0xDEA3, 0xBEE5).
2642+
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0xDE, 0xA3, 0xBE, 0xE5,
2643+
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
2644+
client._resize(0xFFFF, 0xFFFF);
2645+
2646+
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
2647+
client._sock._websocket._receiveData(new Uint8Array(incoming));
2648+
expect(cursorSpy).to.have.been.calledOnceWith();
2649+
cursorSpy.restore();
2650+
2651+
sendMouseMoveEvent(10, 10);
2652+
clock.tick(100);
2653+
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
2654+
10, 10, 0x0);
2655+
});
2656+
2657+
it('should handle remote mouse position updates in pointer lock mode', function() {
2658+
// Simple VMware Cursor Position FBU message with pointer coordinates
2659+
// (0xDEA3, 0xBEE5).
2660+
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0xDE, 0xA3, 0xBE, 0xE5,
2661+
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
2662+
client._resize(0xFFFF, 0xFFFF);
2663+
2664+
const spy = sinon.spy();
2665+
client.addEventListener("pointerlock", spy);
2666+
let stub = sinon.stub(document, 'pointerLockElement');
2667+
stub.get(function () { return client._canvas; });
2668+
client._handlePointerLockChange();
2669+
stub.restore();
2670+
client._sock._websocket._receiveData(new Uint8Array([0x02, 0x02]));
2671+
expect(spy).to.have.been.calledOnce;
2672+
expect(spy.args[0][0].detail.pointerlock).to.be.true;
2673+
2674+
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
2675+
client._sock._websocket._receiveData(new Uint8Array(incoming));
2676+
expect(cursorSpy).to.have.been.calledOnceWith();
2677+
cursorSpy.restore();
2678+
2679+
sendMouseMovementEvent(10, 10);
2680+
clock.tick(100);
2681+
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
2682+
0xDEAD, 0xBEEF, 0x0);
2683+
});
2684+
26302685
describe('Event Aggregation', function () {
26312686
it('should send a single pointer event on mouse movement', function () {
26322687
sendMouseMoveEvent(50, 70);

vnc.html

+5
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ <h1 class="noVNC_logo" translate="no"><span>no</span><br>VNC</h1>
7979
id="noVNC_view_drag_button" class="noVNC_button noVNC_hidden"
8080
title="Move/Drag Viewport">
8181

82+
<!-- Lock pointer events -->
83+
<input type="image" alt="Lock pointer" src="app/images/pointer.svg"
84+
id="noVNC_pointer_lock_button" class="noVNC_button noVNC_hidden"
85+
title="Lock pointer">
86+
8287
<!--noVNC Touch Device only buttons-->
8388
<div id="noVNC_mobile_buttons">
8489
<input type="image" alt="Keyboard" src="app/images/keyboard.svg"

0 commit comments

Comments
 (0)