diff --git a/app/error-handler.js b/app/error-handler.js index c65dcd1a..cf966f47 100644 --- a/app/error-handler.js +++ b/app/error-handler.js @@ -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)); diff --git a/app/images/gamepad.png b/app/images/gamepad.png new file mode 100644 index 00000000..5ea9f1e9 Binary files /dev/null and b/app/images/gamepad.png differ diff --git a/app/images/pointer.svg b/app/images/pointer.svg new file mode 100644 index 00000000..7cbb3c73 --- /dev/null +++ b/app/images/pointer.svg @@ -0,0 +1,78 @@ + + \ No newline at end of file diff --git a/app/images/splash.jpg b/app/images/splash.jpg new file mode 100644 index 00000000..a600b5a9 Binary files /dev/null and b/app/images/splash.jpg differ diff --git a/app/styles/base.css b/app/styles/base.css index 8f272347..a3096d21 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -22,7 +22,8 @@ body { margin:0; padding:0; - font-family: Helvetica; + font-family: "Poppins", "Helvetica"; + letter-spacing: 0.05em; background: white url('../images/icons/kasm_logo.png') no-repeat fixed center; height:100%; touch-action: none; @@ -37,7 +38,7 @@ html { } .noVNC_disabled { - color: rgb(128, 128, 128); + color: rgb(128, 128, 128) !important; } /* ---------------------------------------- @@ -351,8 +352,9 @@ select:active { /* Edge misrenders animations wihthout this */ transform: translateX(0); } + :root.noVNC_connected #noVNC_control_bar_anchor.noVNC_idle { - opacity: 0.8; + /* opacity: 0.8; */ } #noVNC_control_bar_anchor.noVNC_right { left: auto; @@ -365,12 +367,12 @@ select:active { transition: 0.5s ease-in-out; - background-color: rgb(80, 89, 101); + background-color: rgb(9 2 2 / 0.6); border-radius: 0 10px 10px 0; - + border-style: inset; + border-color: rgb(255 255 255 / 0.6); } #noVNC_control_bar.noVNC_open { - box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); left: 0; } #noVNC_control_bar::before { @@ -400,28 +402,31 @@ select:active { #noVNC_control_bar_handle { position: absolute; left: -15px; - top: 0; transform: translateY(35px); width: calc(100% + 30px); height: 50px; z-index: -1; cursor: pointer; border-radius: 5px; - background-color: rgb(83, 99, 122); background-image: url("../images/handle_bg.svg"); background-repeat: no-repeat; background-position: right; - box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5); } #noVNC_control_bar_handle:after { content: ""; transition: transform 0.5s ease-in-out; background: url("../images/handle.svg"); + background-repeat: no-repeat; + background-position: center; position: absolute; - top: 22px; /* (50px-6px)/2 */ - right: 5px; - width: 5px; - height: 6px; + right: 0px; + width: 15px; + height: 60px; + background-color: rgb(9 2 2 / 0.6); + border-bottom-right-radius: 10px; + border-top-right-radius: 10px; + border-color: rgb(255 255 255 / 0.6); + border-style: inset; } #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { transform: translateX(1px) rotate(180deg); @@ -431,6 +436,7 @@ select:active { } .noVNC_right #noVNC_control_bar_handle { background-position: left; + } .noVNC_right #noVNC_control_bar_handle:after { left: 5px; @@ -495,18 +501,23 @@ select:active { transform: translateY(-50%) scale(1); } +.noVNC_button_div { + display: block; + color: #fff; + font-size: 13px; +} + /* General button style */ .noVNC_button { - display: block; + display: inline; padding: 4px 4px; margin: 10px 0; vertical-align: middle; - border:1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; } + .noVNC_button.noVNC_selected { border-color: rgba(0, 0, 0, 0.8); - background: rgba(0, 0, 0, 0.5); + background: rgba(153, 151, 157, 0.68); } .noVNC_button:disabled { opacity: 0.4; @@ -523,10 +534,10 @@ select:active { :root:not(.noVNC_touch) .noVNC_button.noVNC_selected:hover, .noVNC_button.noVNC_selected:focus { border-color: rgba(0, 0, 0, 0.4); - background: rgba(0, 0, 0, 0.2); + background: rgba(153, 151, 157, 0.68); } -:root:not(.noVNC_touch) .noVNC_button:hover, -.noVNC_button:focus { +:root:not(.noVNC_touch) .noVNC_button_div:hover, +.noVNC_button_div:focus { background: rgba(255, 255, 255, 0.2); } .noVNC_button.noVNC_hidden { @@ -539,6 +550,7 @@ select:active { transition: 0.5s ease-in-out; + width: 300px; max-height: 100vh; /* Chrome is buggy with 100% */ overflow-x: hidden; overflow-y: auto; @@ -548,11 +560,9 @@ select:active { padding: 15px; - background: #fff; + background: rgb(9 9 0 / 0.77); border-radius: 10px; color: #000; - border: 2px solid #E0E0E0; - box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); } .noVNC_panel.noVNC_open { visibility: visible; @@ -578,10 +588,11 @@ select:active { .noVNC_panel label { display: block; white-space: nowrap; + color:white; } .noVNC_panel .noVNC_heading { - background-color: rgb(110, 132, 163); + background-color: rgb(54,58,64); border-radius: 5px; padding: 5px; /* Compensate for padding in image */ @@ -602,12 +613,16 @@ select:active { /* Expanders */ .noVNC_expander { cursor: pointer; + color:white; + } .noVNC_expander::before { content: url("../images/expander.svg"); display: inline-block; margin-right: 5px; transition: 0.2s ease-in-out; + -webkit-filter: invert(.75); /* safari 6.0 - 9.0 */ + filter: invert(.75); } .noVNC_expander.noVNC_open::before { transform: rotateZ(90deg); @@ -630,6 +645,14 @@ select:active { text-align: center; } +:root:not(.noVNC_disconnected) .noVNC_hide_on_connect { + display: none +} + +:root:not(.noVNC_connected) .noVNC_hide_on_disconnect { + display: none; +} + :root:not(.noVNC_connected) #noVNC_view_drag_button { display: none; } @@ -687,6 +710,7 @@ select:active { list-style: none; margin: 0px; padding: 0px; + color:white; } #noVNC_setting_port { width: 80px; @@ -776,81 +800,6 @@ select:active { content: url("../images/warning.svg") " "; } -/* ---------------------------------------- - * Connect Dialog - * ---------------------------------------- - */ - -#noVNC_connect_dlg { - transition: 0.5s ease-in-out; - - transform: scale(0, 0); - visibility: hidden; - opacity: 0; -} -#noVNC_connect_dlg.noVNC_open { - transform: scale(1, 1); - visibility: visible; - opacity: 1; -} -#noVNC_connect_dlg .noVNC_logo { - transition: 0.5s ease-in-out; - padding: 10px; - margin-bottom: 10px; - - font-size: 80px; - text-align: center; - - border-radius: 5px; -} -@media (max-width: 440px) { - #noVNC_connect_dlg { - max-width: calc(100vw - 100px); - } - #noVNC_connect_dlg .noVNC_logo { - font-size: calc(25vw - 30px); - } -} -#noVNC_connect_button { - cursor: pointer; - - /* - padding: 10px; - - color: white; - background-color: rgb(110, 132, 163); - border-radius: 12px; - box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); - */ - - text-align: center; - font-size: 20px; - margin-top: 130px; -} -#noVNC_connect_button div { - margin: 2px; - padding: 5px 30px; - border: 1px solid rgb(83, 99, 122); - border-bottom-width: 2px; - border-radius: 5px; - background: linear-gradient(to top, rgb(110, 132, 163), rgb(99, 119, 147)); - - /* This avoids it jumping around when :active */ - vertical-align: middle; - color: white; -} -#noVNC_connect_button div:active { - border-bottom-width: 1px; - margin-top: 3px; -} -:root:not(.noVNC_touch) #noVNC_connect_button div:hover { - background: linear-gradient(to top, rgb(110, 132, 163), rgb(105, 125, 155)); -} - -#noVNC_connect_button img { - vertical-align: bottom; - height: 1.3em; -} /* ---------------------------------------- * Password Dialog @@ -930,21 +879,19 @@ select:active { #noVNC_container { width: 100%; height: 100%; - background-color: rgb(74, 144, 217, 0.5); - border-bottom-right-radius: 800px 600px; - /*border-top-left-radius: 800px 600px;*/ + background-image: url('../images/splash.jpg') } #noVNC_keyboardinput { - width: 1px; - height: 1px; - background-color: #fff; - color: #fff; + width: 0px; + height: 0px; + background-color: #fff0; + color: rgba(5, 5, 5, 0); border: 0; position: absolute; - left: -40px; + left: 35%; + top: 40%; z-index: -1; - ime-mode: disabled; } /*Default noVNC logo.*/ @@ -962,6 +909,10 @@ select:active { font-family: 'Orbitron', 'OrbitronTTF', sans-serif; line-height:90%; text-shadow: 0.1em 0.1em 0 black; + margin-bottom: 0px; +} +.noVNC_logo img { + width: 45% } .noVNC_logo span{ color:green; @@ -1021,6 +972,11 @@ body { user-select: none; } +#noVNC_keyboard_control .noVNC_selected { + background-color:rgb(15, 36, 153); + border: 6px rgb(15, 36, 153) solid; +} + .keyboard-controls .button.ctrl { background-image: url("../images/ctrl.svg"); background-size: contain; @@ -1134,3 +1090,75 @@ body { font-size: 90px; } } + +/* ---------------------------------------- + * Slider Check boxes + * ---------------------------------------- + */ +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 30px; + height: 16px; + margin: 5px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 4px; + bottom: 1px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: #2196F3; +} + +input:focus + .slider { + box-shadow: 0 0 1px #2196F3; +} + +input:checked + .slider:before { + -webkit-transform: translateX(26px); + -ms-transform: translateX(26px); + transform: translateX(10px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} + +.slider.round:before { + border-radius: 50%; +} + +.slider-label { + padding-left: 26px; +} diff --git a/app/ui.js b/app/ui.js index 3d5bb0f0..e5f18d20 100644 --- a/app/ui.js +++ b/app/ui.js @@ -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; @@ -65,8 +66,6 @@ const UI = { controlbarMouseDownClientY: 0, controlbarMouseDownOffsetY: 0, - lastKeyboardinput: null, - defaultKeyboardinputLen: 100, needToCheckClipboardChange: false, inhibitReconnect: true, @@ -129,16 +128,13 @@ const UI = { UI.addControlbarHandlers(); UI.addTouchSpecificHandlers(); UI.addExtraKeysHandlers(); + UI.addGamingHandlers(); UI.addMachineHandlers(); UI.addConnectionControlHandlers(); UI.addClipboardHandlers(); UI.addSettingsHandlers(); document.getElementById("noVNC_status") .addEventListener('click', UI.hideStatus); - - // Bootstrap fallback input handler - UI.keyboardinputReset(); - UI.openControlbar(); UI.updateVisualState('init'); @@ -151,8 +147,6 @@ const UI = { UI.connect(); } else { autoconnect = false; - // Show the connect panel on first load unless autoconnecting - UI.openConnectPanel(); } window.parent.postMessage({ @@ -172,6 +166,12 @@ const UI = { } }); + window.addEventListener("beforeunload", (e) => { + if (UI.rfb) { + UI.disconnect(); + } + }); + return Promise.resolve(UI.rfb); }, @@ -183,10 +183,9 @@ const UI = { document.documentElement.mozRequestFullScreen || document.documentElement.webkitRequestFullscreen || document.body.msRequestFullscreen)) { - document.getElementById('noVNC_fullscreen_button') - .classList.remove("noVNC_hidden"); - UI.addFullscreenHandlers(); - } + UI.showControlInput("noVNC_fullscreen_button") + UI.addFullscreenHandlers(); + } }, initSettings() { @@ -245,6 +244,9 @@ const UI = { UI.initSetting('prefer_local_cursor', true); UI.initSetting('toggle_control_panel', false); UI.initSetting('enable_perf_stats', false); + UI.initSetting('virtual_keyboard_visible', false); + UI.initSetting('enable_ime', false) + UI.toggleKeyboardControls(); if (WebUtil.isInsideKasmVDI()) { UI.initSetting('clipboard_up', false); @@ -350,8 +352,7 @@ const UI = { document.getElementById("noVNC_control_bar") .addEventListener('keydown', UI.keepControlbar); - document.getElementById("noVNC_view_drag_button") - .addEventListener('click', UI.toggleViewDrag); + UI.addClickHandle('noVNC_view_drag_button', UI.toggleViewDrag); document.getElementById("noVNC_control_bar_handle") .addEventListener('mousedown', UI.controlbarHandleMouseDown); @@ -371,12 +372,6 @@ const UI = { addTouchSpecificHandlers() { document.getElementById("noVNC_keyboard_button") .addEventListener('click', UI.toggleVirtualKeyboard); - - UI.touchKeyboard = new Keyboard(document.getElementById('noVNC_keyboardinput')); - UI.touchKeyboard.onkeyevent = UI.keyEvent; - UI.touchKeyboard.grab(); - document.getElementById("noVNC_keyboardinput") - .addEventListener('input', UI.keyInput); document.getElementById("noVNC_keyboardinput") .addEventListener('focus', UI.onfocusVirtualKeyboard); document.getElementById("noVNC_keyboardinput") @@ -410,8 +405,8 @@ const UI = { }, addExtraKeysHandlers() { - document.getElementById("noVNC_toggle_extra_keys_button") - .addEventListener('click', UI.toggleExtraKeys); + UI.addClickHandle('noVNC_toggle_extra_keys_button', UI.toggleExtraKeys); + document.getElementById("noVNC_toggle_ctrl_button") .addEventListener('click', UI.toggleCtrl); document.getElementById("noVNC_toggle_windows_button") @@ -426,20 +421,27 @@ const UI = { .addEventListener('click', UI.sendCtrlAltDel); }, + addGamingHandlers() { + UI.addClickHandle('noVNC_game_mode_button', UI.toggleRelativePointer); + document + .getElementById("noVNC_setting_pointer_lock") + .addEventListener("click", UI.togglePointerLock); + }, + addMachineHandlers() { + UI.addClickHandle('noVNC_power_button', UI.togglePowerPanel); + document.getElementById("noVNC_shutdown_button") .addEventListener('click', () => UI.rfb.machineShutdown()); document.getElementById("noVNC_reboot_button") .addEventListener('click', () => UI.rfb.machineReboot()); document.getElementById("noVNC_reset_button") .addEventListener('click', () => UI.rfb.machineReset()); - document.getElementById("noVNC_power_button") - .addEventListener('click', UI.togglePowerPanel); }, addConnectionControlHandlers() { - document.getElementById("noVNC_disconnect_button") - .addEventListener('click', UI.disconnect); + UI.addClickHandle('noVNC_disconnect_button', UI.disconnect); + var connect_btn_el = document.getElementById("noVNC_connect_button"); if (typeof(connect_btn_el) != 'undefined' && connect_btn_el != null) { @@ -453,8 +455,8 @@ const UI = { }, addClipboardHandlers() { - document.getElementById("noVNC_clipboard_button") - .addEventListener('click', UI.toggleClipboardPanel); + UI.addClickHandle('noVNC_clipboard_button', UI.toggleClipboardPanel); + document.getElementById("noVNC_clipboard_text") .addEventListener('change', UI.clipboardSend); document.getElementById("noVNC_clipboard_clear_button") @@ -474,8 +476,7 @@ const UI = { }, addSettingsHandlers() { - document.getElementById("noVNC_settings_button") - .addEventListener('click', UI.toggleSettingsPanel); + UI.addClickHandle('noVNC_settings_button', UI.toggleSettingsPanel); document.getElementById("noVNC_setting_enable_perf_stats").addEventListener('click', UI.showStats); @@ -536,11 +537,15 @@ 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'); + UI.addSettingChangeHandler('enable_ime', UI.toggleIMEMode); }, addFullscreenHandlers() { - document.getElementById("noVNC_fullscreen_button") - .addEventListener('click', UI.toggleFullscreen); + UI.addClickHandle('noVNC_fullscreen_button', UI.toggleFullscreen); window.addEventListener('fullscreenchange', UI.updateFullscreenButton); window.addEventListener('mozfullscreenchange', UI.updateFullscreenButton); @@ -553,6 +558,20 @@ const UI = { * ============== * VISUAL * ------v------*/ + // Ignore clicks that are propogated from child elements in sub panels + isControlPanelItemClick(e) { + if (!(e && e.target && e.target.classList && e.target.parentNode && + ( + e.target.classList.contains('noVNC_button') && e.target.parentNode.id !== 'noVNC_modifiers' || + e.target.classList.contains('noVNC_button_div') || + e.target.classList.contains('noVNC_heading') + ) + )) { + return false; + } + + return true; + }, // Disable/enable controls depending on connection state updateVisualState(state) { @@ -561,6 +580,7 @@ const UI = { document.documentElement.classList.remove("noVNC_connected"); document.documentElement.classList.remove("noVNC_disconnecting"); document.documentElement.classList.remove("noVNC_reconnecting"); + document.documentElement.classList.remove("noVNC_disconnected"); const transitionElem = document.getElementById("noVNC_transition_text"); if (WebUtil.isInsideKasmVDI()) @@ -583,6 +603,7 @@ const UI = { document.documentElement.classList.add("noVNC_disconnecting"); break; case 'disconnected': + document.documentElement.classList.add("noVNC_disconnected"); break; case 'reconnecting': transitionElem.textContent = _("Reconnecting..."); @@ -616,6 +637,7 @@ const UI = { UI.updatePowerButton(); UI.keepControlbar(); } + //UI.updatePointerLockButton(); // State change closes dialogs as they may not be relevant // anymore @@ -642,7 +664,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') { @@ -737,7 +764,9 @@ const UI = { UI.closeAllPanels(); document.getElementById('noVNC_control_bar') .classList.remove("noVNC_open"); - UI.rfb.focus(); + if (UI.rfb) { + UI.rfb.focus(); + } }, toggleControlbar() { @@ -910,6 +939,48 @@ const UI = { } }, + addClickHandle(domElementName, funcToCall) { + /* Add click handler, will attach to parent if appropriate */ + var control = document.getElementById(domElementName); + if (control.parentNode.classList.contains('noVNC_button_div')) { + control.parentNode.addEventListener('click', funcToCall); + } else { + control.addEventListener('click', funcToCall); + } + }, + + showControlInput(name) { + var control = document.getElementById(name); + /*var control_label = document.getElementById(name + '_label'); + if (control) { + control.classList.remove("noVNC_hidden"); + } + if (control_label) { + control_label.classList.remove("noVNC_hidden"); + } */ + if (control.parentNode.classList.contains('noVNC_button_div')) { + control.parentNode.classList.remove("noVNC_hidden") + } else { + control.classList.remove("noVNC_hidden") + } + }, + + hideControlInput(name) { + var control = document.getElementById(name); + /*var control_label = document.getElementById(name + '_label'); + if (control) { + control.classList.add("noVNC_hidden"); + } + if (control_label) { + control_label.classList.add("noVNC_hidden"); + }*/ + if (control.parentNode.classList.contains('noVNC_button_div')) { + control.parentNode.classList.add("noVNC_hidden") + } else { + control.classList.add("noVNC_hidden") + } + }, + /* ------^------- * /VISUAL * ============== @@ -1076,7 +1147,11 @@ const UI = { .classList.remove("noVNC_selected"); }, - toggleSettingsPanel() { + toggleSettingsPanel(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + if (document.getElementById('noVNC_settings') .classList.contains("noVNC_open")) { UI.closeSettingsPanel(); @@ -1108,7 +1183,11 @@ const UI = { .classList.remove("noVNC_selected"); }, - togglePowerPanel() { + togglePowerPanel(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + if (document.getElementById('noVNC_power') .classList.contains("noVNC_open")) { UI.closePowerPanel(); @@ -1122,11 +1201,9 @@ const UI = { if (UI.connected && UI.rfb.capabilities.power && !UI.rfb.viewOnly) { - document.getElementById('noVNC_power_button') - .classList.remove("noVNC_hidden"); + UI.showControlInput('noVNC_power_button') } else { - document.getElementById('noVNC_power_button') - .classList.add("noVNC_hidden"); + UI.hideControlInput('noVNC_power_button'); // Close power panel if open UI.closePowerPanel(); } @@ -1155,7 +1232,11 @@ const UI = { .classList.remove("noVNC_selected"); }, - toggleClipboardPanel() { + toggleClipboardPanel(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + if (document.getElementById('noVNC_clipboard') .classList.contains("noVNC_open")) { UI.closeClipboardPanel(); @@ -1245,16 +1326,6 @@ const UI = { * CONNECTION * ------v------*/ - openConnectPanel() { - document.getElementById('noVNC_connect_dlg') - .classList.add("noVNC_open"); - }, - - closeConnectPanel() { - document.getElementById('noVNC_connect_dlg') - .classList.remove("noVNC_open"); - }, - connect(event, password) { // Ignore when rfb already exists @@ -1283,8 +1354,6 @@ const UI = { return; } - UI.closeConnectPanel(); - UI.updateVisualState('connecting'); let url; @@ -1297,7 +1366,9 @@ const UI = { } url += '/' + path; - UI.rfb = new RFB(document.getElementById('noVNC_container'), url, + UI.rfb = new RFB(document.getElementById('noVNC_container'), + document.getElementById('noVNC_keyboardinput'), + url, { shared: UI.getSetting('shared'), repeaterID: UI.getSetting('repeaterID'), credentials: { password: password } }); @@ -1310,6 +1381,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'; @@ -1330,11 +1403,13 @@ 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'); UI.rfb.clipboardDown = UI.getSetting('clipboard_down'); UI.rfb.clipboardSeamless = UI.getSetting('clipboard_seamless'); + UI.rfb.keyboard.enableIME = UI.getSetting('enable_ime'); UI.rfb.clipboardBinary = supportsBinaryClipboard() && UI.rfb.clipboardSeamless; //Only explicitly request permission to clipboard on browsers that support binary clipboard access @@ -1368,7 +1443,6 @@ const UI = { } UI.rfb.addEventListener("disconnect", UI.disconnectedRx); document.getElementById('noVNC_control_bar_anchor').setAttribute('style', 'display: none'); - document.getElementById('noVNC_connect_dlg').innerHTML = ''; //keep alive for websocket connection to stay open, since we may not control reverse proxies //send a keep alive within a window that we control @@ -1396,23 +1470,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() { @@ -1449,7 +1523,6 @@ const UI = { UI.updateVisualState('disconnected'); UI.openControlbar(); - UI.openConnectPanel(); }, connectFinished(e) { @@ -1503,7 +1576,6 @@ const UI = { document.title = PAGE_TITLE; UI.openControlbar(); - UI.openConnectPanel(); if (UI.forceReconnect) { UI.forceReconnect = false; @@ -1525,18 +1597,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; @@ -1544,6 +1617,50 @@ 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); + UI.showKeyboardControls(); + } + break; + case 'hide_keyboard_controls': + if (UI.getSetting('virtual_keyboard_visible')) { + UI.forceSetting('virtual_keyboard_visible', true, false); + UI.hideKeyboardControls(); + } + break; + case 'enable_ime_mode': + if (!UI.getSetting('enable_ime')) { + UI.forceSetting('enable_ime', true, false); + UI.toggleIMEMode(); + } + break; + case 'disable_ime_mode': + if (UI.getSetting('enable_ime')) { + UI.forceSetting('enable_ime', false, false); + UI.toggleIMEMode(); + } + break; } } }, @@ -1553,7 +1670,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) { @@ -1656,6 +1781,7 @@ const UI = { document.getElementById('noVNC_fullscreen_button') .classList.remove("noVNC_selected"); } + UI.updatePointerLockButton(); }, /* ------^------- @@ -1708,6 +1834,68 @@ 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) + ) { + UI.showControlInput("noVNC_setting_pointer_lock"); + UI.showControlInput("noVNC_game_mode_button"); + } else { + UI.hideControlInput("noVNC_setting_pointer_lock"); + UI.hideControlInput("noVNC_game_mode_button"); + } + }, + + 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 * ============== @@ -1739,9 +1927,9 @@ const UI = { } if (UI.rfb.clipViewport) { - viewDragButton.classList.remove("noVNC_hidden"); + UI.showControlInput('noVNC_view_drag_button'); } else { - viewDragButton.classList.add("noVNC_hidden"); + UI.hideControlInput('noVNC_view_drag_button'); } }, @@ -1881,18 +2069,41 @@ const UI = { UI.rfb.translateShortcuts = UI.getSetting('translate_shortcuts'); }, + toggleKeyboardControls() { + if (UI.getSetting('virtual_keyboard_visible')) { + UI.showKeyboardControls(); + } else { + UI.hideKeyboardControls(); + } + }, + + toggleIMEMode() { + if (UI.rfb) { + if (UI.getSetting('enable_ime')) { + UI.rfb.keyboard.enableIME = true; + } else { + UI.rfb.keyboard.enableIME = false; + } + } + }, + showKeyboardControls() { - document.querySelector(".keyboard-controls").classList.add("is-visible"); + document.getElementById('noVNC_keyboard_control').classList.add("is-visible"); }, hideKeyboardControls() { - document.querySelector(".keyboard-controls").classList.remove("is-visible"); + document.getElementById('noVNC_keyboard_control').classList.remove("is-visible"); }, showVirtualKeyboard() { const input = document.getElementById('noVNC_keyboardinput'); - if (document.activeElement == input) return; + if (document.activeElement == input || !UI.rfb) return; + + if (UI.getSetting('virtual_keyboard_visible')) { + document.getElementById('noVNC_keyboard_control_handle') + .classList.add("noVNC_selected"); + } input.focus(); @@ -1916,7 +2127,12 @@ const UI = { hideVirtualKeyboard() { const input = document.getElementById('noVNC_keyboardinput'); - if (document.activeElement != input) return; + if (document.activeElement != input || !UI.rfb) return; + + if (UI.getSetting('virtual_keyboard_visible')) { + document.getElementById('noVNC_keyboard_control_handle') + .classList.remove("noVNC_selected"); + } input.blur(); }, @@ -1941,6 +2157,12 @@ const UI = { onblurVirtualKeyboard(event) { document.getElementById('noVNC_keyboard_button') .classList.remove("noVNC_selected"); + + if (UI.getSetting('virtual_keyboard_visible')) { + document.getElementById('noVNC_keyboard_control_handle') + .classList.remove("noVNC_selected"); + } + if (UI.rfb) { UI.rfb.focusOnClick = true; } @@ -1974,83 +2196,6 @@ const UI = { event.preventDefault(); }, - keyboardinputReset() { - const kbi = document.getElementById('noVNC_keyboardinput'); - kbi.value = new Array(UI.defaultKeyboardinputLen).join("_"); - UI.lastKeyboardinput = kbi.value; - }, - - keyEvent(keysym, code, down) { - if (!UI.rfb) return; - - UI.rfb.sendKey(keysym, code, down); - }, - - // When normal keyboard events are left uncought, use the input events from - // the keyboardinput element instead and generate the corresponding key events. - // This code is required since some browsers on Android are inconsistent in - // sending keyCodes in the normal keyboard events when using on screen keyboards. - keyInput(event) { - - if (!UI.rfb) return; - - const newValue = event.target.value; - - if (!UI.lastKeyboardinput) { - UI.keyboardinputReset(); - } - const oldValue = UI.lastKeyboardinput; - - let newLen; - try { - // Try to check caret position since whitespace at the end - // will not be considered by value.length in some browsers - newLen = Math.max(event.target.selectionStart, newValue.length); - } catch (err) { - // selectionStart is undefined in Google Chrome - newLen = newValue.length; - } - const oldLen = oldValue.length; - - let inputs = newLen - oldLen; - let backspaces = inputs < 0 ? -inputs : 0; - - // Compare the old string with the new to account for - // text-corrections or other input that modify existing text - for (let i = 0; i < Math.min(oldLen, newLen); i++) { - if (newValue.charAt(i) != oldValue.charAt(i)) { - inputs = newLen - i; - backspaces = oldLen - i; - break; - } - } - - // Send the key events - for (let i = 0; i < backspaces; i++) { - UI.rfb.sendKey(KeyTable.XK_BackSpace, "Backspace"); - } - for (let i = newLen - inputs; i < newLen; i++) { - UI.rfb.sendKey(keysyms.lookup(newValue.charCodeAt(i))); - } - - // Control the text content length in the keyboardinput element - if (newLen > 2 * UI.defaultKeyboardinputLen) { - UI.keyboardinputReset(); - } else if (newLen < 1) { - // There always have to be some text in the keyboardinput - // element with which backspace can interact. - UI.keyboardinputReset(); - // This sometimes causes the keyboard to disappear for a second - // but it is required for the android keyboard to recognize that - // text has been added to the field - event.target.blur(); - // This has to be ran outside of the input handler in order to work - setTimeout(event.target.focus.bind(event.target), 0); - } else { - UI.lastKeyboardinput = newValue; - } - }, - /* ------^------- * /KEYBOARD * ============== @@ -2082,7 +2227,11 @@ const UI = { .classList.remove("noVNC_selected"); }, - toggleExtraKeys() { + toggleExtraKeys(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + if (document.getElementById('noVNC_modifiers').classList.contains("noVNC_open")) { UI.closeExtraKeys(); } else { @@ -2176,19 +2325,15 @@ const UI = { // Hide input related buttons in view only mode if (UI.rfb.viewOnly) { - document.getElementById('noVNC_keyboard_button') - .classList.add('noVNC_hidden'); - document.getElementById('noVNC_toggle_extra_keys_button') - .classList.add('noVNC_hidden'); - document.getElementById('noVNC_clipboard_button') - .classList.add('noVNC_hidden'); + UI.hideControlInput("noVNC_keyboard_button"); + UI.hideControlInput("noVNC_toggle_extra_keys_button"); + UI.hideControlInput("noVNC_clipboard_button"); + UI.hideControlInput("noVNC_game_mode_button"); } else { - document.getElementById('noVNC_keyboard_button') - .classList.remove('noVNC_hidden'); - document.getElementById('noVNC_toggle_extra_keys_button') - .classList.remove('noVNC_hidden'); - document.getElementById('noVNC_clipboard_button') - .classList.remove('noVNC_hidden'); + UI.showControlInput("noVNC_keyboard_button"); + UI.showControlInput("noVNC_toggle_extra_keys_button"); + UI.showControlInput("noVNC_clipboard_button"); + UI.showControlInput("noVNC_game_mode_button"); } }, @@ -2207,6 +2352,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(); diff --git a/core/encodings.js b/core/encodings.js index 9b6d6c23..c89e3730 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -55,6 +55,7 @@ export const encodings = { pseudoEncodingVideoOutTimeLevel100: -1887, pseudoEncodingVMwareCursor: 0x574d5664, + pseudoEncodingVMwareCursorPosition: 0x574d5666, pseudoEncodingExtendedClipboard: 0xc0a1e5ce }; diff --git a/core/input/imekeys.js b/core/input/imekeys.js new file mode 100644 index 00000000..219e238a --- /dev/null +++ b/core/input/imekeys.js @@ -0,0 +1,32 @@ +/* + * KasmVNC: HTML5 VNC client + * Copyright (C) 2022 Kasm Technologies Inc + * Licensed under MPL 2.0 or any later version (see LICENSE.txt) + */ + +/* + * Keys that could be interaction with IME input + */ + +export default { + 0x30: 'Digit0', + 0x31: 'Digit1', + 0x32: 'Digit2', + 0x33: 'Digit3', + 0x34: 'Digit4', + 0x35: 'Digit5', + 0x36: 'Digit6', + 0x37: 'Digit7', + 0x38: 'Digit8', + 0x39: 'Digit9', + 0x60: 'Numpad0', + 0x61: 'Numpad1', + 0x62: 'Numpad2', + 0x63: 'Numpad3', + 0x64: 'Numpad4', + 0x65: 'Numpad5', + 0x66: 'Numpad6', + 0x67: 'Numpad7', + 0x68: 'Numpad8', + 0x69: 'Numpad9' +}; \ No newline at end of file diff --git a/core/input/keyboard.js b/core/input/keyboard.js index 82b5f5c1..4cd0b28c 100644 --- a/core/input/keyboard.js +++ b/core/input/keyboard.js @@ -8,16 +8,20 @@ import * as Log from '../util/logging.js'; import { stopEvent } from '../util/events.js'; import * as KeyboardUtil from "./util.js"; import KeyTable from "./keysym.js"; +import keysyms from "./keysymdef.js"; +import imekeys from "./imekeys.js"; import * as browser from "../util/browser.js"; import UI from '../../app/ui.js'; +import { isChromiumBased } from '../util/browser.js'; // // Keyboard event handler // export default class Keyboard { - constructor(target) { - this._target = target || null; + constructor(screenInput, touchInput) { + this._screenInput = screenInput; + this._touchInput = touchInput; this._keyDownList = {}; // List of depressed keys // (even if they are happy) @@ -28,11 +32,28 @@ export default class Keyboard { 'keyup': this._handleKeyUp.bind(this), 'keydown': this._handleKeyDown.bind(this), 'blur': this._allKeysUp.bind(this), + 'compositionstart' : this._handleCompositionStart.bind(this), + 'compositionend' : this._handleCompositionEnd.bind(this), + 'input' : this._handleInput.bind(this) }; // ===== EVENT HANDLERS ===== - this.onkeyevent = () => {}; // Handler for key press/release + + this._enableIME = false; + this._imeHold = false; + this._imeInProgress = false; + this._lastKeyboardInput = null; + this._defaultKeyboardInputLen = 100; + this._keyboardInputReset(); + } + + // ===== PUBLIC METHODS ===== + + get enableIME() { return this._enableIME; } + set enableIME(val) { + this._enableIME = val; + this.focus(); } // ===== PRIVATE METHODS ===== @@ -95,10 +116,135 @@ export default class Keyboard { return 'Unidentified'; } + _handleCompositionStart(e) { + Log.Debug("composition started"); + if (this._enableIME) { + this._imeHold = true; + this._imeInProgress = true; + } + } + + _handleCompositionEnd(e) { + Log.Debug("Composition ended"); + if (this._enableIME) { this._imeInProgress = false; } + if (isChromiumBased()) { + this._imeHold = false; + } + } + + _handleInput(e) { + //input event occurs only when keyup keydown events don't prevent default + //IME events will make this happen, for example + //IME changes can back out old characters and replace, thus send differential if IME + //otherwise send new characters + if (this._enableIME && this._imeHold) { + Log.Debug("IME input change, sending differential"); + if (!this._imeInProgress) { + this._imeHold = false; //Firefox fires compisitionend before last input change + } + + const oldValue = this._lastKeyboardInput; + const newValue = e.target.value; + let diff_start = 0; + + //find position where difference starts + for (let i = 0; i < Math.min(oldValue.length, newValue.length); i++) { + if (newValue.charAt(i) != oldValue.charAt(i)) { + break; + } + diff_start++; + } + + //send backspaces if needed + for (let bs = oldValue.length - diff_start; bs > 0; bs--) { + this._sendKeyEvent(KeyTable.XK_BackSpace, "Backspace", true); + this._sendKeyEvent(KeyTable.XK_BackSpace, "Backspace", false); + } + + //send new keys + for (let i = diff_start; i < newValue.length; i++) { + this._sendKeyEvent(keysyms.lookup(newValue.charCodeAt(i)), 'Unidentified', true); + this._sendKeyEvent(keysyms.lookup(newValue.charCodeAt(i)), 'Unidentified', false); + } + this._lastKeyboardInput = newValue; + } else { + Log.Debug("Non-IME input change, sending new characters"); + const newValue = e.target.value; + + if (!this._lastKeyboardInput) { + this._keyboardInputReset(); + } + + const oldValue = this._lastKeyboardInput; + let newLen; + + try { + // Try to check caret position since whitespace at the end + // will not be considered by value.length in some browsers + newLen = Math.max(e.target.selectionStart, newValue.length); + } catch (err) { + // selectionStart is undefined in Google Chrome + newLen = newValue.length; + } + const oldLen = oldValue.length; + + let inputs = newLen - oldLen; + let backspaces = inputs < 0 ? -inputs : 0; + + // Compare the old string with the new to account for + // text-corrections or other input that modify existing text + for (let i = 0; i < Math.min(oldLen, newLen); i++) { + if (newValue.charAt(i) != oldValue.charAt(i)) { + inputs = newLen - i; + backspaces = oldLen - i; + break; + } + } + + // Send the key events + for (let i = 0; i < backspaces; i++) { + this._sendKeyEvent(KeyTable.XK_BackSpace, "Backspace", true); + this._sendKeyEvent(KeyTable.XK_BackSpace, "Backspace", false); + } + for (let i = newLen - inputs; i < newLen; i++) { + this._sendKeyEvent(keysyms.lookup(newValue.charCodeAt(i)), 'Unidentified', true); + this._sendKeyEvent(keysyms.lookup(newValue.charCodeAt(i)), 'Unidentified', false); + } + + // Control the text content length in the keyboardinput element + if (newLen > 2 * this._defaultKeyboardInputLen) { + this._keyboardInputReset(); + } else if (newLen < 1) { + // There always have to be some text in the keyboardinput + // element with which backspace can interact. + this._keyboardInputReset(); + // This sometimes causes the keyboard to disappear for a second + // but it is required for the android keyboard to recognize that + // text has been added to the field + e.target.blur(); + // This has to be ran outside of the input handler in order to work + setTimeout(e.target.focus.bind(e.target), 0); + } else { + this._lastKeyboardInput = newValue; + } + } + } + + _keyboardInputReset() { + this._touchInput.value = new Array(this._defaultKeyboardInputLen).join("_"); + this._lastKeyboardInput = this._touchInput.value; + } + _handleKeyDown(e) { const code = this._getKeyCode(e); let keysym = KeyboardUtil.getKeysym(e); + if (this._isIMEInteraction(e)) { + //skip event if IME related + Log.Debug("Skipping keydown, IME interaction, code: " + code + " keysym: " + keysym + " keycode: " + e.keyCode); + return; + } + // Windows doesn't have a proper AltGr, but handles it using // fake Ctrl+Alt. However the remote end might not be Windows, // so we need to merge those in to a single AltGr event. We @@ -220,10 +366,15 @@ export default class Keyboard { } _handleKeyUp(e) { - stopEvent(e); - const code = this._getKeyCode(e); + if (this._isIMEInteraction(e)) { + //skip IME related events + Log.Debug("Skipping keyup, IME interaction, code: " + code + " keycode: " + e.keyCode); + return; + } + stopEvent(e); + // We can't get a release in the middle of an AltGr sequence, so // abort that detection if (this._altGrArmed) { @@ -271,13 +422,56 @@ export default class Keyboard { Log.Debug("<< Keyboard.allKeysUp"); } + _isIMEInteraction(e) { + //input must come from touchinput (textarea) and ime must be enabled + if (e.target != this._touchInput || !this._enableIME) { return false; } + + //keyCode of 229 is IME composition + if (e.keyCode == 229) { + return true; + } + + //unfortunately, IME interactions can come through as events + //generally safe to ignore and let them come in as "input" events instead + //we can't do that with none character keys though + //Firefox does not seem to fire key events for IME interaction but Chrome does + //TODO: potentially skip this for Firefox browsers, needs more testing with different IME types + if (e.keyCode in imekeys) { + return true; + } + + return false; + } + // ===== PUBLIC METHODS ===== + focus() { + if (this._enableIME) { + this._touchInput.focus(); + } else { + this._screenInput.focus(); + } + } + + blur() { + if (this._enableIME) { + this._touchInput.blur(); + } else { + this._screenInput.blur(); + } + } + grab() { //Log.Debug(">> Keyboard.grab"); - this._target.addEventListener('keydown', this._eventHandlers.keydown); - this._target.addEventListener('keyup', this._eventHandlers.keyup); + this._screenInput.addEventListener('keydown', this._eventHandlers.keydown); + this._screenInput.addEventListener('keyup', this._eventHandlers.keyup); + + this._touchInput.addEventListener('keydown', this._eventHandlers.keydown); + this._touchInput.addEventListener('keyup', this._eventHandlers.keyup); + this._touchInput.addEventListener('compositionstart', this._eventHandlers.compositionstart); + this._touchInput.addEventListener('compositionend', this._eventHandlers.compositionend); + this._touchInput.addEventListener('input', this._eventHandlers.input); // Release (key up) if window loses focus window.addEventListener('blur', this._eventHandlers.blur); @@ -288,8 +482,15 @@ export default class Keyboard { ungrab() { //Log.Debug(">> Keyboard.ungrab"); - this._target.removeEventListener('keydown', this._eventHandlers.keydown); - this._target.removeEventListener('keyup', this._eventHandlers.keyup); + this._screenInput.removeEventListener('keydown', this._eventHandlers.keydown); + this._screenInput.removeEventListener('keyup', this._eventHandlers.keyup); + + this._touchInput.removeEventListener('keydown', this._eventHandlers.keydown); + this._touchInput.removeEventListener('keyup', this._eventHandlers.keyup); + this._touchInput.removeEventListener('compositionstart', this._eventHandlers.compositionstart); + this._touchInput.removeEventListener('compositionend', this._eventHandlers.compositionend); + this._touchInput.removeEventListener('input', this._eventHandlers.input); + window.removeEventListener('blur', this._eventHandlers.blur); // Release (key up) all keys that are in a down state diff --git a/core/rfb.js b/core/rfb.js index 7a4fe0f4..4dccf3eb 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -34,6 +34,7 @@ import HextileDecoder from "./decoders/hextile.js"; import TightDecoder from "./decoders/tight.js"; import TightPNGDecoder from "./decoders/tightpng.js"; import UDPDecoder from './decoders/udp.js'; +import { toSignedRelative16bit } from './util/int.js'; // How many seconds to wait for a disconnect to finish const DISCONNECT_TIMEOUT = 3; @@ -43,7 +44,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) @@ -71,7 +72,7 @@ const extendedClipboardActionNotify = 1 << 27; const extendedClipboardActionProvide = 1 << 28; export default class RFB extends EventTargetMixin { - constructor(target, urlOrChannel, options) { + constructor(target, touchInput, urlOrChannel, options) { if (!target) { throw new Error("Must specify target"); } @@ -172,6 +173,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 = {}; @@ -191,6 +195,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), }; @@ -247,7 +253,7 @@ export default class RFB extends EventTargetMixin { } this._display.onflush = this._onFlush.bind(this); - this._keyboard = new Keyboard(this._canvas); + this._keyboard = new Keyboard(this._canvas, touchInput); this._keyboard.onkeyevent = this._handleKeyEvent.bind(this); this._gestures = new GestureHandler(); @@ -336,6 +342,45 @@ 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; } set clipboardBinary(val) { this._clipboardMode = val; } @@ -748,11 +793,11 @@ export default class RFB extends EventTargetMixin { } focus() { - this._canvas.focus(); + this._keyboard.focus(); } blur() { - this._canvas.blur(); + this._keyboard.blur(); } clipboardPasteFrom(text) { @@ -914,6 +959,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); @@ -1036,6 +1090,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); @@ -1074,11 +1135,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) { @@ -1409,8 +1477,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': @@ -1526,12 +1620,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) { @@ -2352,16 +2488,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); } @@ -2595,6 +2731,10 @@ export default class RFB extends EventTargetMixin { for (let i = 0; i < num; i++) { + if (this._sock.rQwait("Binary Clipboard op id", 4, buffByteLen)) { return false; } + buffByteLen += 4; + let clipid = this._sock.rQshift32(); + if (this._sock.rQwait("Binary Clipboard mimelen", 1, buffByteLen)) { return false; } buffByteLen++; let mimelen = this._sock.rQshift8(); @@ -2635,10 +2775,10 @@ export default class RFB extends EventTargetMixin { ); } - if (!this.clipboardBinary) { continue; } + Log.Info("Processed binary clipboard (ID: " + clipid + ") of MIME " + mime + " of length " + len); + + if (!this.clipboardBinary) { continue; } - Log.Info("Processed binary clipboard of MIME " + mime + " of length " + len); - clipItemData[mime] = new Blob([data], { type: mime }); break; default: @@ -2987,6 +3127,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(); @@ -3125,6 +3268,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/core/util/browser.js b/core/util/browser.js index 39ca4468..23bf6cb5 100644 --- a/core/util/browser.js +++ b/core/util/browser.js @@ -97,6 +97,51 @@ 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); +} + export function isFirefox() { return navigator && !!(/firefox/i).exec(navigator.userAgent); } @@ -107,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); +} + diff --git a/core/util/cursor.js b/core/util/cursor.js index 12bcceda..6d5200c1 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -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 diff --git a/core/util/int.js b/core/util/int.js index 79c9f724..40521ce1 100644 --- a/core/util/int.js +++ b/core/util/int.js @@ -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; -} \ No newline at end of file +} diff --git a/docs/API.md b/docs/API.md index aa5aea7a..af601aba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. \ No newline at end of file diff --git a/tests/test.rfb.js b/tests/test.rfb.js index 09b6d1cc..672d8c98 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -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); diff --git a/vnc.html b/vnc.html index 4025bb6b..6531283d 100644 --- a/vnc.html +++ b/vnc.html @@ -86,300 +86,434 @@ Loading statistics... -