Skip to content

Commit a29358a

Browse files
lhchavezaseering
authored andcommitted
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 90455ee commit a29358a

File tree

6 files changed

+270
-1
lines changed

6 files changed

+270
-1
lines changed

app/images/pointer.svg

+78
Loading

app/ui.js

+45
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ const UI = {
232232
document.getElementById("noVNC_view_drag_button")
233233
.addEventListener('click', UI.toggleViewDrag);
234234

235+
document
236+
.getElementById("noVNC_pointer_lock_button")
237+
.addEventListener("click", UI.requestPointerLock);
238+
235239
document.getElementById("noVNC_control_bar_handle")
236240
.addEventListener('mousedown', UI.controlbarHandleMouseDown);
237241
document.getElementById("noVNC_control_bar_handle")
@@ -451,6 +455,7 @@ const UI = {
451455
UI.updatePowerButton();
452456
UI.keepControlbar();
453457
}
458+
UI.updatePointerLockButton();
454459

455460
// State change closes dialogs as they may not be relevant
456461
// anymore
@@ -1055,6 +1060,7 @@ const UI = {
10551060
UI.rfb.addEventListener("clipboard", UI.clipboardReceive);
10561061
UI.rfb.addEventListener("bell", UI.bell);
10571062
UI.rfb.addEventListener("desktopname", UI.updateDesktopName);
1063+
UI.rfb.addEventListener("pointerlock", UI.pointerLockChanged);
10581064
UI.rfb.clipViewport = UI.getSetting('view_clip');
10591065
UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
10601066
UI.rfb.resizeSession = UI.getSetting('resize') === 'remote';
@@ -1361,6 +1367,33 @@ const UI = {
13611367
/* ------^-------
13621368
* /VIEW CLIPPING
13631369
* ==============
1370+
* POINTER LOCK
1371+
* ------v------*/
1372+
1373+
updatePointerLockButton() {
1374+
// Only show the button if the pointer lock API is properly supported
1375+
if (
1376+
UI.connected &&
1377+
(document.pointerLockElement !== undefined ||
1378+
document.mozPointerLockElement !== undefined)
1379+
) {
1380+
document
1381+
.getElementById("noVNC_pointer_lock_button")
1382+
.classList.remove("noVNC_hidden");
1383+
} else {
1384+
document
1385+
.getElementById("noVNC_pointer_lock_button")
1386+
.classList.add("noVNC_hidden");
1387+
}
1388+
},
1389+
1390+
requestPointerLock() {
1391+
UI.rfb.requestPointerLock();
1392+
},
1393+
1394+
/* ------^-------
1395+
* /POINTER LOCK
1396+
* ==============
13641397
* VIEWDRAG
13651398
* ------v------*/
13661399

@@ -1729,6 +1762,18 @@ const UI = {
17291762
document.title = e.detail.name + " - " + PAGE_TITLE;
17301763
},
17311764

1765+
pointerLockChanged(e) {
1766+
if (e.detail.pointerlock) {
1767+
document
1768+
.getElementById("noVNC_pointer_lock_button")
1769+
.classList.add("noVNC_selected");
1770+
} else {
1771+
document
1772+
.getElementById("noVNC_pointer_lock_button")
1773+
.classList.remove("noVNC_selected");
1774+
}
1775+
},
1776+
17321777
bell(e) {
17331778
if (WebUtil.getConfigVar('bell', 'on') === 'on') {
17341779
const promise = document.getElementById('noVNC_bell').play();

core/encodings.js

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const encodings = {
3030
pseudoEncodingCompressLevel9: -247,
3131
pseudoEncodingCompressLevel0: -256,
3232
pseudoEncodingVMwareCursor: 0x574d5664,
33+
pseudoEncodingVMwareCursorPosition: 0x574d5666,
3334
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
3435
};
3536

core/rfb.js

+64-1
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export default class RFB extends EventTargetMixin {
184184
this._mousePos = {};
185185
this._mouseButtonMask = 0;
186186
this._mouseLastMoveTime = 0;
187+
this._pointerLock = false;
187188
this._viewportDragging = false;
188189
this._viewportDragPos = {};
189190
this._viewportHasMoved = false;
@@ -201,6 +202,7 @@ export default class RFB extends EventTargetMixin {
201202
focusCanvas: this._focusCanvas.bind(this),
202203
handleResize: this._handleResize.bind(this),
203204
handleMouse: this._handleMouse.bind(this),
205+
handlePointerLockChange: this._handlePointerLockChange.bind(this),
204206
handleWheel: this._handleWheel.bind(this),
205207
handleGesture: this._handleGesture.bind(this),
206208
handleRSAAESCredentialsRequired: this._handleRSAAESCredentialsRequired.bind(this),
@@ -493,6 +495,14 @@ export default class RFB extends EventTargetMixin {
493495
this._canvas.blur();
494496
}
495497

498+
requestPointerLock() {
499+
if (this._canvas.requestPointerLock) {
500+
this._canvas.requestPointerLock();
501+
} else if (this._canvas.mozRequestPointerLock) {
502+
this._canvas.mozRequestPointerLock();
503+
}
504+
}
505+
496506
clipboardPasteFrom(text) {
497507
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
498508

@@ -589,6 +599,8 @@ export default class RFB extends EventTargetMixin {
589599
// preventDefault() on mousedown doesn't stop this event for some
590600
// reason so we have to explicitly block it
591601
this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
602+
// This needs to be installed in document instead of the canvas.
603+
document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
592604

593605
// Wheel events
594606
this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
@@ -613,6 +625,7 @@ export default class RFB extends EventTargetMixin {
613625
this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse);
614626
this._canvas.removeEventListener('click', this._eventHandlers.handleMouse);
615627
this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse);
628+
document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
616629
this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
617630
this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
618631
this._resizeObserver.disconnect();
@@ -1025,8 +1038,26 @@ export default class RFB extends EventTargetMixin {
10251038
return;
10261039
}
10271040

1028-
let pos = clientToElement(ev.clientX, ev.clientY,
1041+
let pos;
1042+
if (this._pointerLock) {
1043+
pos = {
1044+
x: this._mousePos.x + ev.movementX,
1045+
y: this._mousePos.y + ev.movementY,
1046+
};
1047+
if (pos.x < 0) {
1048+
pos.x = 0;
1049+
} else if (pos.x > this._fbWidth) {
1050+
pos.x = this._fbWidth;
1051+
}
1052+
if (pos.y < 0) {
1053+
pos.y = 0;
1054+
} else if (pos.y > this._fbHeight) {
1055+
pos.y = this._fbHeight;
1056+
}
1057+
} else {
1058+
pos = clientToElement(ev.clientX, ev.clientY,
10291059
this._canvas);
1060+
}
10301061

10311062
switch (ev.type) {
10321063
case 'mousedown':
@@ -1127,6 +1158,20 @@ export default class RFB extends EventTargetMixin {
11271158
this._mouseLastMoveTime = Date.now();
11281159
}
11291160

1161+
_handlePointerLockChange() {
1162+
if (
1163+
document.pointerLockElement === this._canvas ||
1164+
document.mozPointerLockElement === this._canvas
1165+
) {
1166+
this._pointerLock = true;
1167+
} else {
1168+
this._pointerLock = false;
1169+
}
1170+
this.dispatchEvent(new CustomEvent(
1171+
"pointerlock",
1172+
{ detail: { pointerlock: this._pointerLock }, }));
1173+
}
1174+
11301175
_sendMouse(x, y, mask) {
11311176
if (this._rfbConnectionState !== 'connected') { return; }
11321177
if (this._viewOnly) { return; } // View only, skip mouse events
@@ -2170,6 +2215,8 @@ export default class RFB extends EventTargetMixin {
21702215
encs.push(encodings.pseudoEncodingCursor);
21712216
}
21722217

2218+
encs.push(encodings.pseudoEncodingVMwareCursorPosition);
2219+
21732220
RFB.messages.clientEncodings(this._sock, encs);
21742221
}
21752222

@@ -2576,6 +2623,9 @@ export default class RFB extends EventTargetMixin {
25762623
case encodings.pseudoEncodingVMwareCursor:
25772624
return this._handleVMwareCursor();
25782625

2626+
case encodings.pseudoEncodingVMwareCursorPosition:
2627+
return this._handleVMwareCursorPosition();
2628+
25792629
case encodings.pseudoEncodingCursor:
25802630
return this._handleCursor();
25812631

@@ -2714,6 +2764,19 @@ export default class RFB extends EventTargetMixin {
27142764
return true;
27152765
}
27162766

2767+
_handleVMwareCursorPosition() {
2768+
const x = this._FBU.x;
2769+
const y = this._FBU.y;
2770+
2771+
if (this._pointerLock) {
2772+
// Only attempt to match the server's pointer position if we are in
2773+
// pointer lock mode.
2774+
this._mousePos = { x: x, y: y };
2775+
}
2776+
2777+
return true;
2778+
}
2779+
27172780
_handleCursor() {
27182781
const hotx = this._FBU.x; // hotspot-x
27192782
const hoty = this._FBU.y; // hotspot-y

tests/test.rfb.js

+77
Original file line numberDiff line numberDiff line change
@@ -2822,6 +2822,27 @@ describe('Remote Frame Buffer Protocol Client', function () {
28222822
client._canvas.dispatchEvent(ev);
28232823
}
28242824

2825+
function supportsSendMouseMovementEvent() {
2826+
// Some browsers (like Safari) support the movementX /
2827+
// movementY properties of MouseEvent, but do not allow creation
2828+
// of non-trusted events with those properties.
2829+
let ev;
2830+
2831+
ev = new MouseEvent('mousemove',
2832+
{ 'movementX': 100,
2833+
'movementY': 100 });
2834+
return ev.movementX === 100 && ev.movementY === 100;
2835+
}
2836+
2837+
function sendMouseMovementEvent(dx, dy) {
2838+
let ev;
2839+
2840+
ev = new MouseEvent('mousemove',
2841+
{ 'movementX': dx,
2842+
'movementY': dy });
2843+
client._canvas.dispatchEvent(ev);
2844+
}
2845+
28252846
function sendMouseButtonEvent(x, y, down, button) {
28262847
let pos = elementToClient(x, y);
28272848
let ev;
@@ -2935,6 +2956,62 @@ describe('Remote Frame Buffer Protocol Client', function () {
29352956
50, 70, 0x0);
29362957
});
29372958

2959+
it('should ignore remote cursor position updates', function () {
2960+
if (!supportsSendMouseMovementEvent()) {
2961+
this.skip();
2962+
return;
2963+
}
2964+
// Simple VMware Cursor Position FBU message with pointer coordinates
2965+
// (50, 50).
2966+
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
2967+
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
2968+
client._resize(100, 100);
2969+
2970+
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
2971+
client._sock._websocket._receiveData(new Uint8Array(incoming));
2972+
expect(cursorSpy).to.have.been.calledOnceWith();
2973+
cursorSpy.restore();
2974+
2975+
expect(client._mousePos).to.deep.equal({ });
2976+
sendMouseMoveEvent(10, 10);
2977+
clock.tick(100);
2978+
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
2979+
10, 10, 0x0);
2980+
});
2981+
2982+
it('should handle remote mouse position updates in pointer lock mode', function () {
2983+
if (!supportsSendMouseMovementEvent()) {
2984+
this.skip();
2985+
return;
2986+
}
2987+
// Simple VMware Cursor Position FBU message with pointer coordinates
2988+
// (50, 50).
2989+
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
2990+
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
2991+
client._resize(100, 100);
2992+
2993+
const spy = sinon.spy();
2994+
client.addEventListener("pointerlock", spy);
2995+
let stub = sinon.stub(document, 'pointerLockElement');
2996+
stub.get(function () { return client._canvas; });
2997+
client._handlePointerLockChange();
2998+
stub.restore();
2999+
client._sock._websocket._receiveData(new Uint8Array([0x02, 0x02]));
3000+
expect(spy).to.have.been.calledOnce;
3001+
expect(spy.args[0][0].detail.pointerlock).to.be.true;
3002+
3003+
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
3004+
client._sock._websocket._receiveData(new Uint8Array(incoming));
3005+
expect(cursorSpy).to.have.been.calledOnceWith();
3006+
cursorSpy.restore();
3007+
3008+
expect(client._mousePos).to.deep.equal({ x: 50, y: 50 });
3009+
sendMouseMovementEvent(10, 10);
3010+
clock.tick(100);
3011+
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
3012+
60, 60, 0x0);
3013+
});
3014+
29383015
describe('Event Aggregation', function () {
29393016
it('should send a single pointer event on mouse movement', function () {
29403017
sendMouseMoveEvent(50, 70);

0 commit comments

Comments
 (0)