Pointer lock api (#29)

Add pointer lock and relative cursor position support. Game mode enables both pointer lock and relative cursor positions.
This commit is contained in:
Matt McClaskey 2022-05-03 10:37:22 -04:00 committed by GitHub
parent 6c708d8254
commit 1753425874
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 682 additions and 58 deletions

View File

@ -26,6 +26,12 @@
return false;
}
// Skip allowed errors
let allowedErrors = [ "The user has exited the lock before this request was completed." ];
if (event.message && allowedErrors.includes(event.message)) {
return false;
}
let div = document.createElement("div");
div.classList.add('noVNC_message');
div.appendChild(document.createTextNode(event.message));

BIN
app/images/gamepad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

78
app/images/pointer.svg Normal file
View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="pointer.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/keyboard.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#717171"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="6.9841519"
inkscape:cy="18.584699"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="false"
inkscape:window-width="2560"
inkscape:window-height="1403"
inkscape:window-x="2560"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:object-nodes="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true"
inkscape:document-rotation="0">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 6.3910823,1030.3965 v 17.497 l 3.5465954,-2.6671 1.5862113,4.1015 4.336661,-1.5752 -1.59331,-4.2624 4.341678,-0.3562 z"
id="path879" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

206
app/ui.js
View File

@ -34,7 +34,7 @@ import "core-js/stable";
import "regenerator-runtime/runtime";
import * as Log from '../core/util/logging.js';
import _, { l10n } from './localization.js';
import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold, supportsBinaryClipboard, isFirefox, isWindows, isIOS }
import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold, supportsBinaryClipboard, isFirefox, isWindows, isIOS, supportsPointerLock }
from '../core/util/browser.js';
import { setCapture, getPointerEvent } from '../core/util/events.js';
import KeyTable from "../core/input/keysym.js";
@ -47,6 +47,7 @@ const PAGE_TITLE = "KasmVNC";
var delta = 500;
var lastKeypressTime = 0;
var lastKeypressCode = -1;
var currentEventCount = -1;
var idleCounter = 0;
@ -350,6 +351,10 @@ const UI = {
document.getElementById("noVNC_view_drag_button")
.addEventListener('click', UI.toggleViewDrag);
document
.getElementById("noVNC_setting_pointer_lock")
.addEventListener("click", UI.togglePointerLock);
document.getElementById("noVNC_control_bar_handle")
.addEventListener('mousedown', UI.controlbarHandleMouseDown);
document.getElementById("noVNC_control_bar_handle")
@ -415,6 +420,8 @@ const UI = {
.addEventListener('click', UI.sendEsc);
document.getElementById("noVNC_send_ctrl_alt_del_button")
.addEventListener('click', UI.sendCtrlAltDel);
document.getElementById("noVNC_game_mode_button")
.addEventListener("click", UI.toggleRelativePointer)
},
addMachineHandlers() {
@ -527,6 +534,7 @@ const UI = {
UI.addSettingChangeHandler('clipboard_seamless');
UI.addSettingChangeHandler('clipboard_up');
UI.addSettingChangeHandler('clipboard_down');
UI.addSettingChangeHandler('toggle_control_panel');
UI.addSettingChangeHandler('virtual_keyboard_visible');
UI.addSettingChangeHandler('virtual_keyboard_visible', UI.toggleKeyboardControls);
UI.addSettingChangeHandler('enable_ime');
@ -611,6 +619,7 @@ const UI = {
UI.updatePowerButton();
UI.keepControlbar();
}
//UI.updatePointerLockButton();
// State change closes dialogs as they may not be relevant
// anymore
@ -637,7 +646,12 @@ const UI = {
},
showStatus(text, statusType, time) {
showStatus(text, statusType, time, kasm = false) {
// If inside the full Kasm CDI framework, don't show messages unless explicitly told to
if (WebUtil.isInsideKasmVDI() && !kasm) {
return;
}
const statusElem = document.getElementById('noVNC_status');
if (typeof statusType === 'undefined') {
@ -1307,6 +1321,8 @@ const UI = {
UI.rfb.addEventListener("bottleneck_stats", UI.bottleneckStatsRecieve);
UI.rfb.addEventListener("bell", UI.bell);
UI.rfb.addEventListener("desktopname", UI.updateDesktopName);
UI.rfb.addEventListener("inputlock", UI.inputLockChanged);
UI.rfb.addEventListener("inputlockerror", UI.inputLockError);
UI.rfb.translateShortcuts = UI.getSetting('translate_shortcuts');
UI.rfb.clipViewport = UI.getSetting('view_clip');
UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
@ -1327,6 +1343,7 @@ const UI = {
UI.rfb.compressionLevel = parseInt(UI.getSetting('compression'));
UI.rfb.showDotCursor = UI.getSetting('show_dot');
UI.rfb.idleDisconnect = UI.getSetting('idle_disconnect');
UI.rfb.pointerRelative = UI.getSetting('pointer_relative');
UI.rfb.videoQuality = parseInt(UI.getSetting('video_quality'));
UI.rfb.antiAliasing = UI.getSetting('anti_aliasing');
UI.rfb.clipboardUp = UI.getSetting('clipboard_up');
@ -1394,23 +1411,23 @@ const UI = {
document.getElementById('noVNC_status').style.visibility = "visible";
}
// Send an event to the parent document (kasm app) to toggle the control panel when ctl is double clicked
if (UI.getSetting('toggle_control_panel', false)) {
document.addEventListener('keyup', function (event) {
// CTRL and the various implementations of the mac command key
if ([17, 224, 91, 93].indexOf(event.keyCode) > -1) {
var thisKeypressTime = new Date();
if (thisKeypressTime - lastKeypressTime <= delta) {
UI.toggleNav();
thisKeypressTime = 0;
//key events for KasmVNC control
document.addEventListener('keyup', function (event) {
if (event.ctrlKey && event.shiftKey) {
switch(event.keyCode) {
case 49:
UI.toggleNav();
break;
case 50:
UI.toggleRelativePointer();
break;
case 51:
UI.togglePointerLock();
break;
}
}
lastKeypressTime = thisKeypressTime;
}
}, true);
}
}, true);
},
disconnect() {
@ -1523,18 +1540,19 @@ const UI = {
UI.showStatus(msg, 'error');
},
/*
Menu.js Additions
*/
receiveMessage(event) {
//TODO: UNCOMMENT FOR PRODUCTION
//if (event.origin !== "https://kasmweb.com")
// return;
//send message to parent window
sendMessage(name, value) {
if (WebUtil.isInsideKasmVDI()) {
parent.postMessage({ action: name, value: value }, '*' );
}
},
//receive message from parent window
receiveMessage(event) {
if (event.data && event.data.action) {
switch (event.data.action) {
case 'clipboardsnd':
if (UI.rfb.clipboardUp) {
if (UI.rfb && UI.rfb.clipboardUp) {
UI.rfb.clipboardPasteFrom(event.data.value);
}
break;
@ -1542,6 +1560,26 @@ const UI = {
UI.forceSetting('video_quality', parseInt(event.data.value), false);
UI.updateQuality();
break;
case 'enable_game_mode':
if (UI.rfb && !UI.rfb.pointerRelative) {
UI.toggleRelativePointer();
}
break;
case 'disable_game_mode':
if (UI.rfb && UI.rfb.pointerRelative) {
UI.toggleRelativePointer();
}
break;
case 'enable_pointer_lock':
if (UI.rfb && !UI.rfb.pointerLock) {
UI.togglePointerLock();
}
break;
case 'disable_pointer_lock':
if (UI.rfb && UI.rfb.pointerLock) {
UI.togglePointerLock();
}
break;
case 'show_keyboard_controls':
if (!UI.getSetting('virtual_keyboard_visible')) {
UI.forceSetting('virtual_keyboard_visible', true, false);
@ -1575,7 +1613,15 @@ const UI = {
},
toggleNav(){
parent.postMessage({ action: 'togglenav', value: null}, '*' );
if (WebUtil.isInsideKasmVDI()) {
parent.postMessage({ action: 'togglenav', value: null}, '*' );
} else {
UI.toggleControlbar();
UI.keepControlbar();
UI.activateControlbar();
UI.controlbarGrabbed = false;
UI.showControlbarHint(false);
}
},
clipboardRx(event) {
@ -1678,6 +1724,7 @@ const UI = {
document.getElementById('noVNC_fullscreen_button')
.classList.remove("noVNC_selected");
}
UI.updatePointerLockButton();
},
/* ------^-------
@ -1730,6 +1777,76 @@ const UI = {
UI.updateViewDrag();
},
/* ------^-------
* /VIEW CLIPPING
* ==============
* POINTER LOCK
* ------v------*/
updatePointerLockButton() {
// Only show the button if the pointer lock API is properly supported
// AND in fullscreen.
if (
UI.connected &&
(document.pointerLockElement !== undefined ||
document.mozPointerLockElement !== undefined)
) {
document
.getElementById("noVNC_setting_pointer_lock")
.classList.remove("noVNC_hidden");
document
.getElementById("noVNC_game_mode_button")
.classList.remove("noVNC_hidden");
} else {
document
.getElementById("noVNC_setting_pointer_lock")
.classList.add("noVNC_hidden");
document
.getElementById("noVNC_game_mode_button")
.classList.add("noVNC_hidden");
}
},
togglePointerLock() {
if (!supportsPointerLock()) {
UI.showStatus('Your browser does not support pointer lock.', 'info', 1500, true);
//force pointer lock in UI to false and disable control
UI.forceSetting('pointer_lock', false, true);
} else {
UI.rfb.pointerLock = !UI.rfb.pointerLock;
if (UI.getSetting('pointer_lock') !== UI.rfb.pointerLock) {
UI.forceSetting('pointer_lock', UI.rfb.pointerLock, false);
}
}
},
toggleRelativePointer(event=null, forcedToggleValue=null) {
if (!supportsPointerLock()) {
UI.showStatus('Your browser does not support pointer lock.', 'info', 1500, true);
return;
}
var togglePosition = !UI.rfb.pointerRelative;
if (UI.rfb.pointerLock !== togglePosition) {
UI.rfb.pointerLock = togglePosition;
}
if (UI.rfb.pointerRelative !== togglePosition) {
UI.rfb.pointerRelative = togglePosition;
}
if (togglePosition) {
document.getElementById('noVNC_game_mode_button').classList.add("noVNC_selected");
} else {
document.getElementById('noVNC_game_mode_button').classList.remove("noVNC_selected");
UI.forceSetting('pointer_lock', false, false);
}
UI.sendMessage('enable_game_mode', togglePosition);
UI.sendMessage('enable_pointer_lock', togglePosition);
},
/* ------^-------
* /VIEW CLIPPING
* ==============
@ -2161,6 +2278,8 @@ const UI = {
.classList.add('noVNC_hidden');
document.getElementById('noVNC_clipboard_button')
.classList.add('noVNC_hidden');
document.getElementById('noVNC_game_mode_button')
.classList.add('noVNC_hidden');
} else {
document.getElementById('noVNC_keyboard_button')
.classList.remove('noVNC_hidden');
@ -2168,6 +2287,8 @@ const UI = {
.classList.remove('noVNC_hidden');
document.getElementById('noVNC_clipboard_button')
.classList.remove('noVNC_hidden');
document.getElementById('noVNC_game_mode_button')
.classList.remove('noVNC_hidden');
}
},
@ -2186,6 +2307,39 @@ const UI = {
document.title = e.detail.name + " - " + PAGE_TITLE;
},
inputLockChanged(e) {
var pointer_lock_el = document.getElementById("noVNC_setting_pointer_lock");
var pointer_rel_el = document.getElementById("noVNC_game_mode_button");
if (e.detail.pointer) {
pointer_lock_el.checked = true;
UI.sendMessage('enable_pointer_lock', true);
UI.closeControlbar();
UI.showStatus('Press Esc Key to Exit Pointer Lock Mode', 'warn', 5000, true);
} else {
//If in game mode
if (UI.rfb.pointerRelative) {
UI.showStatus('Game Mode paused, click on screen to resume Game Mode.', 'warn', 5000, true);
} else {
UI.forceSetting('pointer_lock', false, false);
document.getElementById('noVNC_game_mode_button')
.classList.remove("noVNC_selected");
UI.sendMessage('enable_pointer_lock', false);
}
}
},
inputLockError(e) {
UI.showStatus('Unable to enter pointer lock mode.', 'warn', 5000, true);
UI.rfb.pointerRelative = false;
document.getElementById('noVNC_game_mode_button').classList.remove("noVNC_selected");
UI.forceSetting('pointer_lock', false, false);
UI.sendMessage('enable_game_mode', false);
UI.sendMessage('enable_pointer_lock', false);
},
bell(e) {
if (WebUtil.getConfigVar('bell', 'on') === 'on') {
const promise = document.getElementById('noVNC_bell').play();

View File

@ -54,6 +54,7 @@ export const encodings = {
pseudoEncodingVideoOutTimeLevel100: -1887,
pseudoEncodingVMwareCursor: 0x574d5664,
pseudoEncodingVMwareCursorPosition: 0x574d5666,
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
};

View File

@ -33,6 +33,7 @@ import RREDecoder from "./decoders/rre.js";
import HextileDecoder from "./decoders/hextile.js";
import TightDecoder from "./decoders/tight.js";
import TightPNGDecoder from "./decoders/tightpng.js";
import { toSignedRelative16bit } from './util/int.js';
// How many seconds to wait for a disconnect to finish
const DISCONNECT_TIMEOUT = 3;
@ -42,7 +43,7 @@ var _videoQuality = 2;
var _enableWebP = false;
// Minimum wait (ms) between two mouse moves
const MOUSE_MOVE_DELAY = 17;
const MOUSE_MOVE_DELAY = 17;
// Wheel thresholds
let WHEEL_LINE_HEIGHT = 19; // Pixels for one line step (on Windows)
@ -170,6 +171,9 @@ export default class RFB extends EventTargetMixin {
this._mousePos = {};
this._mouseButtonMask = 0;
this._mouseLastMoveTime = 0;
this._pointerLock = false;
this._pointerLockPos = { x: 0, y: 0 };
this._pointerRelativeEnabled = false;
this._mouseLastPinchAndZoomTime = 0;
this._viewportDragging = false;
this._viewportDragPos = {};
@ -189,6 +193,8 @@ 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),
handlePointerLockError: this._handlePointerLockError.bind(this),
handleWheel: this._handleWheel.bind(this),
handleGesture: this._handleGesture.bind(this),
};
@ -333,6 +339,43 @@ export default class RFB extends EventTargetMixin {
// ===== PROPERTIES =====
get pointerLock() { return this._pointerLock; }
set pointerLock(value) {
if (!this._pointerLock) {
if (this._canvas.requestPointerLock) {
this._canvas.requestPointerLock();
this._pointerLockChanging = true;
} else if (this._canvas.mozRequestPointerLock) {
this._canvas.mozRequestPointerLock();
this._pointerLockChanging = true;
}
} else {
if (window.document.exitPointerLock) {
window.document.exitPointerLock();
this._pointerLockChanging = true;
} else if (window.document.mozExitPointerLock) {
window.document.mozExitPointerLock();
this._pointerLockChanging = true;
}
}
}
get pointerRelative() { return this._pointerRelativeEnabled; }
set pointerRelative(value)
{
this._pointerRelativeEnabled = value;
if (value) {
let max_w = ((this._display.scale === 1) ? this._fbWidth : (this._fbWidth * this._display.scale));
let max_h = ((this._display.scale === 1) ? this._fbHeight : (this._fbHeight * this._display.scale));
this._pointerLockPos.x = Math.floor(max_w / 2);
this._pointerLockPos.y = Math.floor(max_h / 2);
// reset the cursor position to center
this._mousePos = { x: this._pointerLockPos.x , y: this._pointerLockPos.y };
this._cursor.move(this._pointerLockPos.x, this._pointerLockPos.y);
}
}
get keyboard() { return this._keyboard; }
get clipboardBinary() { return this._clipboardMode; }
@ -748,12 +791,10 @@ export default class RFB extends EventTargetMixin {
focus() {
this._keyboard.focus();
//this._canvas.focus();
}
blur() {
this._keyboard.blur();
//this._canvas.blur();
}
clipboardPasteFrom(text) {
@ -915,6 +956,15 @@ export default class RFB extends EventTargetMixin {
// reason so we have to explicitly block it
this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
// Pointer Lock listeners need to be installed in document instead of the canvas.
if (document.onpointerlockchange !== undefined) {
document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError, false);
} else if (document.onmozpointerlockchange !== undefined) {
document.addEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError, false);
}
// Wheel events
this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
@ -938,6 +988,13 @@ 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);
if (document.onpointerlockchange !== undefined) {
document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError);
} else if (document.onmozpointerlockchange !== undefined) {
document.removeEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError);
}
this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
window.removeEventListener('resize', this._eventHandlers.windowResize);
@ -976,11 +1033,18 @@ export default class RFB extends EventTargetMixin {
value: null
}, "*");
// Re-enable pointerLock if relative cursor is enabled
// pointerLock must come from user initiated event
if (!this._pointerLock && this._pointerRelativeEnabled) {
this.pointerLock = true;
}
if (!this.focusOnClick) {
return;
}
this.focus();
}
_setDesktopName(name) {
@ -1311,8 +1375,34 @@ export default class RFB extends EventTargetMixin {
return;
}
let pos = clientToElement(ev.clientX, ev.clientY,
let pos;
if (this._pointerLock && !this._pointerRelativeEnabled) {
let max_w = ((this._display.scale === 1) ? this._fbWidth : (this._fbWidth * this._display.scale));
let max_h = ((this._display.scale === 1) ? this._fbHeight : (this._fbHeight * this._display.scale));
pos = {
x: this._mousePos.x + ev.movementX,
y: this._mousePos.y + ev.movementY,
};
if (pos.x < 0) {
pos.x = 0;
} else if (pos.x > max_w) {
pos.x = max_w;
}
if (pos.y < 0) {
pos.y = 0;
} else if (pos.y > max_h) {
pos.y = max_h;
}
this._cursor.move(pos.x, pos.y);
} else if (this._pointerLock && this._pointerRelativeEnabled) {
pos = {
x: this._mousePos.x + ev.movementX,
y: this._mousePos.y + ev.movementY,
};
} else {
pos = clientToElement(ev.clientX, ev.clientY,
this._canvas);
}
switch (ev.type) {
case 'mousedown':
@ -1428,12 +1518,54 @@ export default class RFB extends EventTargetMixin {
this._mouseLastMoveTime = Date.now();
}
_handlePointerLockChange(env) {
if (
document.pointerLockElement === this._canvas ||
document.mozPointerLockElement === this._canvas
) {
this._pointerLock = true;
this._cursor.setEmulateCursor(true);
} else {
this._pointerLock = false;
this._cursor.setEmulateCursor(false);
}
this.dispatchEvent(new CustomEvent(
"inputlock",
{ detail: { pointer: this._pointerLock }, }));
}
_handlePointerLockError() {
this._pointerLockChanging = false;
this.dispatchEvent(new CustomEvent(
"inputlockerror",
{ detail: { pointer: this._pointerLock }, }));
}
_sendMouse(x, y, mask) {
if (this._rfbConnectionState !== 'connected') { return; }
if (this._viewOnly) { return; } // View only, skip mouse events
RFB.messages.pointerEvent(this._sock, this._display.absX(x),
if (this._pointerLock && this._pointerRelativeEnabled) {
// Use releative cursor position
var rel_16_x = toSignedRelative16bit(x - this._pointerLockPos.x);
var rel_16_y = toSignedRelative16bit(y - this._pointerLockPos.y);
//console.log("new_pos x" + x + ", y" + y);
//console.log("lock x " + this._pointerLockPos.x + ", y " + this._pointerLockPos.y);
//console.log("rel x " + rel_16_x + ", y " + rel_16_y);
RFB.messages.pointerEvent(this._sock, rel_16_x,
rel_16_y, mask);
// reset the cursor position to center
this._mousePos = { x: this._pointerLockPos.x , y: this._pointerLockPos.y };
this._cursor.move(this._pointerLockPos.x, this._pointerLockPos.y);
} else {
RFB.messages.pointerEvent(this._sock, this._display.absX(x),
this._display.absY(y), mask);
}
}
_sendScroll(x, y, dX, dY) {
@ -2254,16 +2386,16 @@ export default class RFB extends EventTargetMixin {
encs.push(encodings.pseudoEncodingVideoScalingLevel0 + this.videoScaling);
encs.push(encodings.pseudoEncodingFrameRateLevel10 + this.frameRate - 10);
encs.push(encodings.pseudoEncodingMaxVideoResolution);
// preferBandwidth choses preset settings. Since we expose all the settings, lets not pass this
// preferBandwidth choses preset settings. Since we expose all the settings, lets not pass this
if (this.preferBandwidth) // must be last - server processes in reverse order
encs.push(encodings.pseudoEncodingPreferBandwidth);
if (supportsCursorURIs && this._fbDepth == 24) {
if (this.preferLocalCursor || !isTouchDevice) {
encs.push(encodings.pseudoEncodingVMwareCursor);
encs.push(encodings.pseudoEncodingCursor);
}
if (this._fbDepth == 24) {
encs.push(encodings.pseudoEncodingVMwareCursor);
encs.push(encodings.pseudoEncodingCursor);
}
encs.push(encodings.pseudoEncodingVMwareCursorPosition);
RFB.messages.clientEncodings(this._sock, encs);
}
@ -2795,6 +2927,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();
@ -2933,6 +3068,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

View File

@ -97,6 +97,47 @@ export function isSafari() {
navigator.userAgent.indexOf('Chrome') === -1);
}
// Returns IE version number if IE or older Edge browser
export function isIE() {
var ua = window.navigator.userAgent;
// Test values; Uncomment to check result &
// IE 10
// ua = 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0)';
// IE 11
// ua = 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko';
// Edge 12 (Spartan)
// ua = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36 Edge/12.0';
// Edge 13
// ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586';
var msie = ua.indexOf('MSIE ');
var ie_ver = false;
if (msie > 0) {
// IE 10 or older => return version number
ie_ver = parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10);
}
var trident = ua.indexOf('Trident/');
if (trident > 0) {
// IE 11 => return version number
var rv = ua.indexOf('rv:');
ie_ver = parseInt(ua.substring(rv + 3, ua.indexOf('.', rv)), 10);
}
var edge = ua.indexOf('Edge/');
if (edge > 0) {
// Edge (IE 12+) => return version number
ie_ver = parseInt(ua.substring(edge + 5, ua.indexOf('.', edge)), 10);
}
return ie_ver;
}
export function isChromiumBased() {
return (!!window.chrome);
}
@ -111,3 +152,10 @@ export function supportsBinaryClipboard() {
return (navigator.clipboard && typeof navigator.clipboard.read === "function");
}
export function supportsPointerLock() {
//Older versions of edge do support browser lock, but seems to not behave as expected
//Disable on browsers that don't fully support or work as expected
if (isIOS() || isIE()) { return false; }
return (document.exitPointerLock);
}

View File

@ -6,21 +6,19 @@
import { supportsCursorURIs, isTouchDevice } from './browser.js';
const useFallback = !supportsCursorURIs || isTouchDevice;
const needsFallback = !supportsCursorURIs || isTouchDevice;
export default class Cursor {
constructor() {
this._target = null;
this._canvas = document.createElement('canvas');
if (useFallback) {
this._canvas.style.position = 'fixed';
this._canvas.style.zIndex = '65535';
this._canvas.style.pointerEvents = 'none';
// Can't use "display" because of Firefox bug #1445997
this._canvas.style.visibility = 'hidden';
}
this._canvas.style.position = 'fixed';
this._canvas.style.zIndex = '65535';
this._canvas.style.pointerEvents = 'none';
// Can't use "display" because of Firefox bug #1445997
this._canvas.style.visibility = 'hidden';
this._useFallback = needsFallback;
this._position = { x: 0, y: 0 };
this._hotSpot = { x: 0, y: 0 };
@ -40,9 +38,15 @@ export default class Cursor {
this._target = target;
if (useFallback) {
document.body.appendChild(this._canvas);
document.body.appendChild(this._canvas);
if (needsFallback) {
// Only add the event listeners if this will be responsible for
// rendering the cursor all the time. Otherwise, the cursor will
// only be rendered then the forced emulation is turned on, and
// that doesn't require this class to be adjusting the cursor
// position.
const options = { capture: true, passive: true };
this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options);
this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options);
@ -58,16 +62,16 @@ export default class Cursor {
return;
}
if (useFallback) {
if (needsFallback) {
const options = { capture: true, passive: true };
this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options);
this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options);
this._target.removeEventListener('mousemove', this._eventHandlers.mousemove, options);
this._target.removeEventListener('mouseup', this._eventHandlers.mouseup, options);
document.body.removeChild(this._canvas);
}
document.body.removeChild(this._canvas);
this._target = null;
}
@ -91,9 +95,10 @@ export default class Cursor {
ctx.clearRect(0, 0, w, h);
ctx.putImageData(img, 0, 0);
if (useFallback) {
if (this._useFallback || needsFallback) {
this._updatePosition();
} else {
}
if (!needsFallback) {
let url = this._canvas.toDataURL();
this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
}
@ -112,7 +117,7 @@ export default class Cursor {
// Mouse events might be emulated, this allows
// moving the cursor in such cases
move(clientX, clientY) {
if (!useFallback) {
if (!this._useFallback) {
return;
}
// clientX/clientY are relative the _visual viewport_,
@ -130,6 +135,22 @@ export default class Cursor {
this._updateVisibility(target);
}
// Force the use of cursor emulation. This is needed when the pointer lock
// is in use, since the browser will not render the cursor.
setEmulateCursor(emulate) {
if (needsFallback) {
// We need to use the fallback all the time, so we shouldn't update
// the fallback flag.
return;
}
this._useFallback = emulate;
if (this._useFallback) {
this._showCursor();
} else {
this._hideCursor();
}
}
_handleMouseOver(event) {
// This event could be because we're entering the target, or
// moving around amongst its sub elements. Let the move handler

View File

@ -15,12 +15,40 @@ export function toSigned32bit(toConvert) {
}
/*
* Fast hashing function with low entropy, not for security uses.
* Converts a signed 32bit integer to a signed 16bit int
* Uses second most significant bit to represent it is relative
*/
export function toSignedRelative16bit(toConvert) {
// TODO: move these so they are not computed with every func call
var negmask16 = 1 << 15;
var negmask32 = 1 << 31;
var relmask16 = 1 << 14;
var converted16 = toConvert | 0;
// number is negative
if ((toConvert & negmask32) != 0) {
// clear the 32bit negative bit
// not neccessary because the last 16bits will get dropped anyway
converted16 *= -1;
// set the 16bit negative bit
converted16 |= negmask16;
// set the relative bit
converted16 |= relmask16;
} else {
// set the relative bit
converted16 |= relmask16;
}
return converted16;
}
/* Fast hashing function with low entropy */
export function hashUInt8Array(data) {
let h;
for (let i = 0; i < data.length; i++) {
h = Math.imul(31, h) + data[i] | 0;
}
return h;
}
}

View File

@ -113,6 +113,10 @@ protocol stream.
- The `capabilities` event is fired when `RFB.capabilities` is
updated.
[`inputlock`](#inputlock)
- The `inputlock` event is fired when an input lock is acquired (or released)
by the canvas.
### Methods
[`RFB.disconnect()`](#rfbdisconnect)
@ -146,6 +150,10 @@ protocol stream.
[`RFB.clipboardPasteFrom()`](#rfbclipboardPasteFrom)
- Send clipboard contents to server.
[`inputlock`](#inputlock)
- The `inputlock` event is fired when an input lock is acquired (or released)
by the canvas.
### Details
#### RFB()
@ -262,6 +270,15 @@ The `capabilities` event is fired whenever an entry is added or removed
from `RFB.capabilities`. The `detail` property is an `Object` with the
property `capabilities` containing the new value of `RFB.capabilities`.
#### inputlock
The `inputlock` event is fired after a request to acquire an input lock or
whenever the state of the canvas' input lock has changed, the latter typically
occurs because the lock was released by the user pressing the ESC key or
performing a browser-specific gesture. The `detail` property is an `Object`
with the property `pointer` containing whether the Pointer Lock is currently
held or not.
#### RFB.disconnect()
The `RFB.disconnect()` method is used to disconnect from the currently
@ -383,3 +400,24 @@ to the remote server.
**`text`**
- A `DOMString` specifying the clipboard data to send.
#### RFB.requestInputLock()
The `RFB.requestInputLock()` method is used to request that the RFB canvas hold
an input lock. An `inputlock` event will be fired with the result of the
acquisition of the requested locks.
##### Syntax
RFB.requestInputLock( { pointer: true } );
###### Parameters
**`pointer`**
- Requests to acquire a [Pointer
Lock](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API),
which hides the local mouse cursor and provides relative motion events.
This must be called directly from an event handler where a user has
directly interacted with an element through an [engagement
gesture](https://w3c.github.io/pointerlock/#dfn-engagement-gesture) (e.g. a
click or touch event) for the browser to allow this.

View File

@ -2610,6 +2610,27 @@ describe('Remote Frame Buffer Protocol Client', function () {
client._canvas.dispatchEvent(ev);
}
function supportsSendMouseMovementEvent() {
// Some browsers (like Safari) support the movementX /
// movementY properties of MouseEvent, but do not allow creation
// of non-trusted events with those properties.
let ev;
ev = new MouseEvent('mousemove',
{ 'movementX': 100,
'movementY': 100 });
return ev.movementX === 100 && ev.movementY === 100;
}
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;
@ -2723,6 +2744,62 @@ describe('Remote Frame Buffer Protocol Client', function () {
50, 70, 0x0);
});
it('should ignore remote cursor position updates', function () {
if (!supportsSendMouseMovementEvent()) {
this.skip();
return;
}
// Simple VMware Cursor Position FBU message with pointer coordinates
// (50, 50).
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
client._resize(100, 100);
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
client._sock._websocket._receiveData(new Uint8Array(incoming));
expect(cursorSpy).to.have.been.calledOnceWith();
cursorSpy.restore();
expect(client._mousePos).to.deep.equal({ });
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 () {
if (!supportsSendMouseMovementEvent()) {
this.skip();
return;
}
// Simple VMware Cursor Position FBU message with pointer coordinates
// (50, 50).
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
client._resize(100, 100);
const spy = sinon.spy();
client.addEventListener("inputlock", 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.pointer).to.be.true;
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
client._sock._websocket._receiveData(new Uint8Array(incoming));
expect(cursorSpy).to.have.been.calledOnceWith();
cursorSpy.restore();
expect(client._mousePos).to.deep.equal({ x: 50, y: 50 });
sendMouseMovementEvent(10, 10);
clock.tick(100);
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
60, 60, 0x0);
});
describe('Event Aggregation', function () {
it('should send a single pointer event on mouse movement', function () {
sendMouseMoveEvent(50, 70);

View File

@ -168,6 +168,11 @@
id="noVNC_fullscreen_button" class="noVNC_button noVNC_hidden"
title="Fullscreen">
<!-- Toggle game mode -->
<input type="image" alt="Game Mode" src="app/images/gamepad.png"
id="noVNC_game_mode_button" class="noVNC_button noVNC_hidden"
title="Game Pointer Mode">
<!-- Settings -->
<input type="image" alt="Settings" src="app/images/settings.svg"
id="noVNC_settings_button" class="noVNC_button"
@ -197,12 +202,15 @@
<input id="noVNC_setting_translate_shortcuts" type="checkbox" />Translate keyboard shurtcuts
</label>
</li>
</li>
<li>
<label><input id="noVNC_setting_enable_webp" type="checkbox" /> Enable WebP Compression</label></li>
<li>
<label><input id="noVNC_setting_enable_perf_stats" type="checkbox" /> Enable Performance Stats</label></li>
<li>
<label>
<input type="checkbox" id="noVNC_setting_pointer_lock" /> Enable Pointer Lock
</label>
</li>
<label><input id="noVNC_setting_enable_ime" type="checkbox" /> IME Input Mode</label></li>
<li>
<label><input id="noVNC_setting_virtual_keyboard_visible" type="checkbox" /> Show Virtual Keyboard Control</label></li>
@ -227,6 +235,23 @@
</select>
</li>
<li><hr></li>
<li>
<div class="noVNC_expander">Keyboard Shortcuts</div>
<div>
<ul>
<li>
<label>
<input id="noVNC_setting_toggle_control_panel" type="checkbox" /> Enable KasmVNC Keyboard Shortcuts
</label>
</li>
<li>Ctrl+Shift+</li>
<li>1 - Toggle Control Panel</li>
<li>2 - Toggle Game Pointer Mode</li>
<li>3 - Toggle Pointer Lock</li>
</ul>
</div>
</li>
<li><hr></li>
<li>
<div class="noVNC_expander">Stream Quality</div>
<div><ul>