diff --git a/README.md b/README.md index d77cb519..e2cb5564 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For support with KasmVNC, post on the [KasmVNC Project](https://github.com/kasmt - Automatic mixing of webp and jpeg based on CPU availability on server - WebRTC UDP Transit - Lossless QOI Image format for Local LAN - - [Dynamic jpeg/webp image coompression](https://github.com/kasmtech/KasmVNC/wiki/Video-Rendering-Options#dynamic-image-quality) quality settings based on screen change rates + - [Dynamic jpeg/webp image compression](https://github.com/kasmtech/KasmVNC/wiki/Video-Rendering-Options#dynamic-image-quality) quality settings based on screen change rates - Seemless clipboard support (on Chromium based browsers) - Binary clipboard support for text, images, and formatted text (on Chromium based browsers) - Allow client to set/change most configuration settings diff --git a/app/images/desktop-regular.svg b/app/images/desktop-regular.svg new file mode 100644 index 00000000..cd265db1 --- /dev/null +++ b/app/images/desktop-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/styles/base.css b/app/styles/base.css index 5a71fdc8..9a3abcd5 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -897,7 +897,8 @@ select:active { #noVNC_container { width: 100%; height: 100%; - background-image: url('../images/splash.jpg') + background-image: url('../images/splash.jpg'); + background-size: cover; } #noVNC_keyboardinput { @@ -1191,3 +1192,204 @@ a:link { a:visited { color: white; } + +/* ---------------------------------------- + * Multi Display Arrangement + * ---------------------------------------- + */ +#noVNC_connect_button { + background: rgba(0,0,0,0.7); + padding: 20px; + color: white; + font-size: 12px; + font-weight: 100; + display: flex; + align-items: center; + gap: 20px; + cursor: pointer; + border-radius: 8px; +} +#noVNC_connect_button .image { + color:#697ad6; + transition: all 0.3s; + position: relative; + overflow: hidden; +} +#noVNC_connect_button .text { + color: #ffffff91 +} +#noVNC_connect_button .heading { + color: #fff; + font-size: 13px; + margin-bottom: 3px; +} +#noVNC_connect_button:hover .image { + color:#298453; + +} +#noVNC_connect_button:hover .go { + transition: all 0.3s; + transform: translateX(5px); +} +#noVNC_connect_button .power { + transition: all 0.3s; + color: white!important; + transform: translateY(50px); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; +} +#noVNC_connect_button:hover .power { + transform: translateY(0); +} +#noVNC_connect_button svg { + fill: currentColor; +} +#noVNC_identify_monitor { + position: fixed; + z-index: 999999; + background: rgba(0,0,0,0.7); + color: white; + font-size: 30px; + top: 0; + right: 0; + width: 100px; + height: 100px; + display: none; + justify-content: center; + align-items: center; + border-bottom-left-radius: 10px; +} +#noVNC_identify_monitor.show { + display: flex; +} +.flex { + display: flex; +} +.flex-col { + flex-direction: column; +} +.canvas { + background: rgb(225,225,226); + background: linear-gradient(0deg, rgba(225,225,226,1) 0%, rgba(237,237,238,1) 100%); +} +.row { + background: #e9e9ea; + border-radius: 8px; + border: 1px solid #dcdcdd; + padding: 6px 15px; + display: flex; + justify-content: space-between; + align-items: center; +} +.row input { + border: 1px solid #dcdcdd; + padding: 8px 2px; + text-align: right; + background-color: #ededee; +} + + #noVNC_displays { + position: fixed; + display: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100; + background: rgba(0,0,0,0.7); + + cursor: pointer; + + transition: 0.5s ease-in-out; + + + padding: 5px; + flex-direction: row; + justify-content: center; + align-items: center; + + line-height: 25px; + word-wrap: break-word; + +} +#noVNC_displays.noVNC_open { + display: flex; + transform: translateY(0); + visibility: visible; + opacity: 1; +} +#noVNC_displays .canvas { + display: flex; + width: 700px; + flex-direction: column; + border-radius: 15px; + background: #fdfdfd; + padding: 15px 20px; + box-shadow: 0 0 15px rgba(0,0,0,0.4); + position: relative; +} +#noVNC_displays .canvas-title { + font-size: 14px; + font-weight: bold; + padding: 0 10px; + line-height: 1; + line-height: 1.4; +} +#noVNC_displays .canvas-text { + font-size: 12px; + padding: 0 35px 0 10px; + opacity: 0.6; + line-height: 1.4; + margin-bottom: 15px; +} +#noVNC_displays .arrange-buttons { + margin-top: 15px; + display: flex; + justify-content: space-between; +} +#noVNC_displays .arrange-buttons button, #noVNC_displays .arrange-buttons .button { + border: none; + display: flex; + align-items: center; + padding: 4px 7px; + border-radius: 5px; + cursor: pointer; + background-color: #f1f1f1; + font-size: 13px; + border: 1px solid #e5e5e5; + line-height: 1; +} +#noVNC_setting_enable_hidpi_option { + display: none!important; +} +#noVNC_setting_enable_hidpi_option.show { + display: flex!important; +} +#noVNC_refreshMonitors { + position: absolute; + top: 20px; + right: 25px; +} +#noVNC_refreshMonitors_icon { + transition: all 0.3s; + transform-origin: center; +} +#noVNC_addMonitor { + background-color: #2196F3!important; + color: white; + border: none!important; +} +#noVNC_addMonitor svg { + margin-right: 5px; +} +#noVNC_displays .canvas canvas { + background: #f7f7f7; + border: 1px solid #ececec; + border-radius: 8px; +} diff --git a/app/ui.js b/app/ui.js index 3d674856..8aa37465 100644 --- a/app/ui.js +++ b/app/ui.js @@ -13,13 +13,7 @@ window.addEventListener("load", function() { loader.src = "vendor/browser-es-module-loader/dist/browser-es-module-loader.js"; document.head.appendChild(loader); }); -window.addEventListener("load", function() { - var connect_btn_el = document.getElementById("noVNC_connect_button"); - if (typeof(connect_btn_el) != 'undefined' && connect_btn_el != null) - { - connect_btn_el.click(); - } -}); + window.updateSetting = (name, value) => { WebUtil.writeSetting(name, value); @@ -31,8 +25,8 @@ window.updateSetting = (name, value) => { } } -import "core-js/stable"; -import "regenerator-runtime/runtime"; +//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, supportsPointerLock } @@ -68,6 +62,12 @@ const UI = { inhibitReconnect: true, reconnectCallback: null, reconnectPassword: null, + monitors: [], + sortedMonitors: [], + selectedMonitor: null, + refreshRotation: 0, + + supportsBroadcastChannel: (typeof BroadcastChannel !== "undefined"), prime() { return WebUtil.initSettings().then(() => { @@ -131,11 +131,12 @@ const UI = { UI.addConnectionControlHandlers(); UI.addClipboardHandlers(); UI.addSettingsHandlers(); + UI.addDisplaysHandler(); + // UI.addMultiMonitorAddHandler(); document.getElementById("noVNC_status") .addEventListener('click', UI.hideStatus); UI.openControlbar(); - // UI.updateVisualState('init'); @@ -165,8 +166,8 @@ const UI = { UI.hideKeyboardControls(); } }); - - window.addEventListener("beforeunload", (e) => { + + window.addEventListener("unload", (e) => { if (UI.rfb) { UI.disconnect(); } @@ -402,6 +403,17 @@ const UI = { } }, + addConnectionControlHandlers() { + UI.addClickHandle('noVNC_disconnect_button', UI.disconnect); + + var connect_btn_el = document.getElementById("noVNC_connect_button_2"); + if (typeof(connect_btn_el) != 'undefined' && connect_btn_el != null) + { + connect_btn_el.addEventListener('click', UI.connect); + } + + }, + addTouchSpecificHandlers() { document.getElementById("noVNC_keyboard_button") .addEventListener('click', UI.toggleVirtualKeyboard); @@ -472,21 +484,6 @@ const UI = { .addEventListener('click', () => UI.rfb.machineReset()); }, - addConnectionControlHandlers() { - 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) - { - connect_btn_el.addEventListener('click', UI.connect); - } - document.getElementById("noVNC_cancel_reconnect_button") - .addEventListener('click', UI.cancelReconnect); - - document.getElementById("noVNC_credentials_button") - .addEventListener('click', UI.setCredentials); - }, - addClipboardHandlers() { UI.addClickHandle('noVNC_clipboard_button', UI.toggleClipboardPanel); @@ -588,6 +585,24 @@ const UI = { window.addEventListener('msfullscreenchange', UI.updateFullscreenButton); }, + addDisplaysHandler() { + if (UI.supportsBroadcastChannel) { + UI.showControlInput("noVNC_displays_button"); + UI.addClickHandle('noVNC_displays_button', UI.openDisplays); + UI.addClickHandle('noVNC_close_displays', UI.closeDisplays); + UI.addClickHandle('noVNC_identify_monitors_button', UI._identify); + UI.addClickHandle('noVNC_addMonitor', UI.addSecondaryMonitor); + UI.addClickHandle('noVNC_refreshMonitors', UI.displaysRefresh); + + } + }, + + /*addMultiMonitorAddHandler() { + if (UI.supportsBroadcastChannel) { + UI.addClickHandle('noVNC_addmonitor_button', UI.addSecondaryMonitor); + } + },*/ + /* ------^------- * /EVENT HANDLERS * ============== @@ -676,8 +691,6 @@ const UI = { // State change closes dialogs as they may not be relevant // anymore UI.closeAllPanels(); - document.getElementById('noVNC_credentials_dlg') - .classList.remove('noVNC_open'); }, showStats() { @@ -1380,9 +1393,12 @@ const UI = { UI.rfb = new RFB(document.getElementById('noVNC_container'), document.getElementById('noVNC_keyboardinput'), url, - { shared: UI.getSetting('shared'), - repeaterID: UI.getSetting('repeaterID'), - credentials: { password: password } }); + { + shared: UI.getSetting('shared'), + repeaterID: UI.getSetting('repeaterID'), + credentials: { password: password } + }, + true ); UI.rfb.addEventListener("connect", UI.connectFinished); UI.rfb.addEventListener("disconnect", UI.disconnectFinished); UI.rfb.addEventListener("credentialsrequired", UI.credentials); @@ -1394,6 +1410,7 @@ const UI = { UI.rfb.addEventListener("desktopname", UI.updateDesktopName); UI.rfb.addEventListener("inputlock", UI.inputLockChanged); UI.rfb.addEventListener("inputlockerror", UI.inputLockError); + UI.rfb.addEventListener("screenregistered", UI.screenRegistered); UI.rfb.translateShortcuts = UI.getSetting('translate_shortcuts'); UI.rfb.clipViewport = UI.getSetting('view_clip'); UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale'; @@ -1427,7 +1444,7 @@ const UI = { UI.rfb.mouseButtonMapper = UI.initMouseButtonMapper(); if (UI.rfb.videoQuality === 5) { UI.rfb.enableQOI = true; - } + } //Only explicitly request permission to clipboard on browsers that support binary clipboard access if (supportsBinaryClipboard()) { @@ -1681,6 +1698,14 @@ const UI = { UI.toggleIMEMode(); } break; + case 'open_displays_mode': + if (UI.rfb) { + UI.openDisplays() + } + break; + case 'close_displays_mode': + UI.closeDisplays() + break; case 'enable_webrtc': if (!UI.getSetting('enable_webrtc')) { UI.forceSetting('enable_webrtc', true, false); @@ -1722,6 +1747,10 @@ const UI = { UI.forceSetting('enable_hidpi', event.data.value, false); UI.enableHiDpi(); break; + case 'control_displays': + parent.postMessage({ action: 'can_control_displays', value: true}, '*' ); + break; + } } }, @@ -1746,57 +1775,6 @@ const UI = { parent.postMessage({ action: 'clipboardrx', value: event.detail.text}, '*' ); //TODO fix star }, -/* ------^------- - * /CONNECTION - * ============== - * PASSWORD - * ------v------*/ - - credentials(e) { - // FIXME: handle more types - - document.getElementById("noVNC_username_block").classList.remove("noVNC_hidden"); - document.getElementById("noVNC_password_block").classList.remove("noVNC_hidden"); - - let inputFocus = "none"; - if (e.detail.types.indexOf("username") === -1) { - document.getElementById("noVNC_username_block").classList.add("noVNC_hidden"); - } else { - inputFocus = inputFocus === "none" ? "noVNC_username_input" : inputFocus; - } - if (e.detail.types.indexOf("password") === -1) { - document.getElementById("noVNC_password_block").classList.add("noVNC_hidden"); - } else { - inputFocus = inputFocus === "none" ? "noVNC_password_input" : inputFocus; - } - document.getElementById('noVNC_credentials_dlg') - .classList.add('noVNC_open'); - - setTimeout(() => document - .getElementById(inputFocus).focus(), 100); - - Log.Warn("Server asked for credentials"); - UI.showStatus(_("Credentials are required"), "warning"); - }, - - setCredentials(e) { - // Prevent actually submitting the form - e.preventDefault(); - - let inputElemUsername = document.getElementById('noVNC_username_input'); - const username = inputElemUsername.value; - - let inputElemPassword = document.getElementById('noVNC_password_input'); - const password = inputElemPassword.value; - // Clear the input after reading the password - inputElemPassword.value = ""; - - UI.rfb.sendCredentials({ username: username, password: password }); - UI.reconnectPassword = password; - document.getElementById('noVNC_credentials_dlg') - .classList.remove('noVNC_open'); - }, - /* ------^------- * /PASSWORD * ============== @@ -1867,6 +1845,345 @@ const UI = { UI.rfb.enableHiDpi = UI.getSetting('enable_hidpi'); }, +/* ------^------- + * /MULTI-MONITOR SUPPORT + * ==============*/ + + _identify(e) { + UI.identify() + UI.rfb.identify(UI.monitors) + }, + + identify(data) { + document.getElementById('noVNC_identify_monitor').innerHTML = '1' + document.getElementById('noVNC_identify_monitor').classList.add("show") + setTimeout(() => { + document.getElementById('noVNC_identify_monitor').classList.remove("show") + }, 3500) + }, + + openDisplays() { + document.getElementById('noVNC_displays').classList.add("noVNC_open"); + if (UI.monitors.length < 1 ) { + let screenPlan = UI.rfb.getScreenPlan(); + UI.initMonitors(screenPlan) + } + UI.displayMonitors() + }, + + closeDisplays() { + document.getElementById('noVNC_displays').classList.remove("noVNC_open"); + }, + + displaysRefresh() { + const rotation = UI.refreshRotation + 180; + let screenPlan = UI.rfb.getScreenPlan(); + document.getElementById('noVNC_refreshMonitors_icon').style.transform = "rotate(" + rotation + "deg)" + UI.refreshRotation = rotation + UI.updateMonitors(screenPlan) + UI.recenter() + UI.draw() + }, + + addSecondaryMonitor() { + let new_display_path = window.location.pathname.replace(/[^/]*$/, '') + let new_display_url = `${window.location.protocol}//${window.location.host}${new_display_path}screen.html`; + + Log.Debug(`Opening a secondary display ${new_display_url}`) + window.open(new_display_url); + }, + + initMonitors(screenPlan) { + const { scale } = UI.multiMonitorSettings() + let monitors = [] + let showNativeResolution = false + let num = 1 + screenPlan.screens.forEach(screen => { + if (parseFloat(screen.pixelRatio) != 1) { + showNativeResolution = true + } + monitors.push({ + id: screen.screenID, + x: screen.x / scale, + y: screen.y / scale, + w: screen.serverWidth / scale, + h: screen.serverHeight / scale, + pixelRatio: screen.pixelRatio, + scale: 1, + fill: '#eeeeeecc', + isDragging: false, + num + }) + num++ + }) + if (showNativeResolution) { + document.getElementById('noVNC_setting_enable_hidpi_option').classList.add("show"); + } else { + document.getElementById('noVNC_setting_enable_hidpi_option').classList.remove("show"); + } + UI.monitors = monitors + let deepCopyMonitors = JSON.parse(JSON.stringify(monitors)) + UI.sortedMonitors = deepCopyMonitors.sort((a, b) => { + if (a.y >= b.y + (b.h / 2)) { + return 1 + } + return a.x - b.x + }) + + }, + + updateMonitors(screenPlan) { + UI.initMonitors(screenPlan) + UI.recenter() + UI.draw() + }, + + multiMonitorSettings() { + const canvas = document.getElementById("noVNC_multiMonitorWidget") + return { + canvas, + ctx: canvas.getContext("2d"), + bb: canvas.getBoundingClientRect(), + scale: 12, + canvasWidth: 700, + canvasHeight: 230, + } + }, + + recenter() { + const monitors = UI.sortedMonitors + UI.removeSpaces() + const { startLeft, startTop } = UI.getSizes(monitors) + + for (var i = 0; i < monitors.length; i++) { + var m = monitors[i]; + m.x += startLeft + m.y += startTop + } + UI.setScreenPlan() + }, + + removeSpaces() { + const monitors = UI.sortedMonitors + let prev = monitors[0] + if (monitors.length > 1) { + for (var i = 1; i < monitors.length; i++) { + var a = monitors[i]; + let prevStart = prev.x + prev.w + let prevStartTop = prev.y + prev.h + if (a.x > prevStart) { + a.x = prevStart + } + if (a.x < prevStart) { + if (a.y < prevStartTop) { + a.x = prevStart + } + } + if (a.y > prevStartTop) { + if (a.x <= prevStart) { + a.y = prevStartTop + } + } + prev = monitors[i] + } + } + }, + + rect(ctx, x, y, w, h) { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, 5); + ctx.stroke(); + ctx.closePath(); + ctx.fill(); + }, + + draw() { + const { ctx, canvasWidth, canvasHeight, scale } = UI.multiMonitorSettings() + const monitors = UI.sortedMonitors + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + ctx.rect(0, 0, canvasWidth, canvasHeight); + + for (var i = 0; i < monitors.length; i++) { + var m = monitors[i]; + ctx.fillStyle = m.fill; + ctx.lineWidth = 1; + ctx.lineJoin = "round"; + ctx.strokeStyle = m === UI.selectedMonitor ? "#2196F3" : "#aaa"; + UI.rect(ctx, m.x, m.y, (m.w / m.scale), (m.h / m.scale)); + ctx.font = "13px sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "top"; + ctx.fillStyle = "#000"; + ctx.fillText((m.num), (m.x + m.w) - 4, m.y + 4); + ctx.font = "200 11px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(m.w * scale + ' x ' + m.h * scale, m.x + (m.w / 2), m.y + (m.h / 2)); + } + + }, + + getSizes(monitors) { + const { canvasWidth, canvasHeight } = UI.multiMonitorSettings() + let top = monitors[0].y + let left = monitors[0].x + let width = monitors[0].w + let height = monitors[0].h + for (var i = 0; i < monitors.length; i++) { + var m = monitors[i]; + if (m.x < left) { + left = m.x + } + if (m.y < top) { + top = m.y + } + if(m.x + m.w > width) { + width = m.x + m.w + } + if(m.y + m.h > height) { + height = m.y + m.h + } + } + const startLeft = ((canvasWidth - width - left) / 2); + const startTop = ((canvasHeight - height - top) / 2); + + return { top, left, width, height, startLeft, startTop } + }, + + setScreenPlan() { + let monitors = UI.monitors + let sortedMonitors = UI.sortedMonitors + const { scale } = UI.multiMonitorSettings() + const { top, left, width, height } = UI.getSizes(sortedMonitors) + const screens = [] + for (var i = 0; i < monitors.length; i++) { + var monitor = monitors[i]; + var a = sortedMonitors.find(el => el.id === monitor.id) + console.log(a) + screens.push({ + screenID: a.id, + serverHeight: Math.round(a.h * scale), + serverWidth: Math.round(a.w * scale), + x: Math.round((a.x - left) * scale), + y: Math.round((a.y - top) * scale) + }) + } + const screenPlan = { + serverHeight: Math.round(height * scale), + serverWidth: Math.round(width * scale), + screens + } + console.log('setScreenPlan') + console.log(screenPlan) + UI.rfb.applyScreenPlan(screenPlan); + }, + + + + displayMonitors() { + // const monitors = UI.sortedMonitors + let monitors = UI.sortedMonitors + const { canvas, ctx, bb, canvasWidth, canvasHeight, scale } = UI.multiMonitorSettings() + const { startLeft, startTop } = UI.getSizes(monitors) + let offsetX + let offsetY + let dragok = false + let startX; + let startY; + + offsetX = bb.left + offsetY = bb.top + + canvas.addEventListener("mousedown", myDown, false); + canvas.addEventListener("mouseup", myUp, false); + canvas.addEventListener("mousemove", myMove, false); + UI.recenter() + UI.draw() + + function myDown(e) { + let monitors = UI.sortedMonitors + e.preventDefault(); + e.stopPropagation(); + let mx = parseInt(e.clientX - offsetX); + let my = parseInt(e.clientY - offsetY); + for (var i = 0; i < monitors.length; i++) { + var mon = monitors[i]; + var monw = mon.w / mon.scale + var monh = mon.h / mon.scale + let monx = mon.x + let mony = mon.y + // Find the closest rect to drag + if (mx > monx && mx < (monx + monw) && my > mony && my < (mony + monh)) { + dragok = true; + mon.isDragging = true; + UI.selectedMonitor = mon + break // get out of the loop rather than dragging multiple + } + } + startX = mx; + startY = my; + UI.draw() + } + function myUp(e) { + let monitors = UI.sortedMonitors + e.preventDefault(); + e.stopPropagation(); + + // clear all the dragging flags + dragok = false; + for (var i = 0; i < monitors.length; i++) { + monitors[i].isDragging = false; + } + monitors.sort((a, b) => { + if (a.y >= b.y + (b.h / 2)) { + return 1 + } + return a.x - b.x + }) + UI.recenter() + UI.draw() + } + function myMove(e) { + let monitors = UI.sortedMonitors + if (dragok) { + e.preventDefault(); + e.stopPropagation(); + + // get the current mouse position + var mx = parseInt(e.clientX - offsetX); + var my = parseInt(e.clientY - offsetY); + + // calculate the distance the mouse has moved + // since the last mousemove + var dx = mx - startX; + var dy = my - startY; + + // move each rect that isDragging + // by the distance the mouse has moved + // since the last mousemove + for (var i = 0; i < monitors.length; i++) { + var m = monitors[i]; + if (m.isDragging) { + m.x += dx; + m.y += dy; + } + } + + // redraw the scene with the new rect positions + UI.draw(); + + // reset the starting mouse position for the next mousemove + startX = mx; + startY = my; + + } + } + + }, + + + /* ------^------- * /RESIZE * ============== @@ -2514,6 +2831,20 @@ const UI = { } }, + screenRegistered(e) { + console.log('screen registered') + // Get the current screen plan + // When a new display is added, it is defaulted to be placed to the far right relative to existing displays and to the top + if (UI.rfb) { + let screenPlan = UI.rfb.getScreenPlan(); + console.log(screenPlan) + + UI.updateMonitors(screenPlan) + UI._identify(UI.monitors) + } + + }, + //Helper to add options to dropdown. addOption(selectbox, text, value) { const optn = document.createElement("OPTION"); diff --git a/app/ui_screen.js b/app/ui_screen.js new file mode 100644 index 00000000..d2b0044f --- /dev/null +++ b/app/ui_screen.js @@ -0,0 +1,333 @@ +import RFB from "../core/rfb.js"; +import * as WebUtil from "./webutil.js"; +import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold, supportsBinaryClipboard, isFirefox, isWindows, isIOS, supportsPointerLock } + from '../core/util/browser.js'; +import { MouseButtonMapper, XVNC_BUTTONS } from "../core/mousebuttonmapper.js"; +import * as Log from '../core/util/logging.js'; + +const UI = { + connected: false, + screenID: null, + screen: {}, + screens: [], + supportsBroadcastChannel: (typeof BroadcastChannel !== "undefined"), + controlChannel: null, + //Initial Loading of the UI + prime() { + console.log('prime') + this.start(); + }, + + //Render default UI + start() { + console.log('start') + window.addEventListener("unload", (e) => { + if (UI.rfb) { + UI.disconnect(); + } + }); + + // Settings with immediate effects + UI.initSetting('logging', 'warn'); + UI.updateLogging(); + + UI.addDefaultHandlers(); + UI.updateVisualState('disconnected'); + }, + + addDefaultHandlers() { + document.getElementById('noVNC_connect_button').addEventListener('click', UI.connect); + }, + + getSetting(name, isBool, default_value) { + let val = WebUtil.readSetting(name); + if ((val === 'undefined' || val === null) && default_value !== 'undefined' && default_value !== null) { + val = default_value; + } + if (typeof val !== 'undefined' && val !== null && isBool) { + if (val.toString().toLowerCase() in {'0': 1, 'no': 1, 'false': 1}) { + val = false; + } else { + val = true; + } + } + return val; + }, + + connect() { + console.log('connect') + UI.rfb = new RFB(document.getElementById('noVNC_container'), + document.getElementById('noVNC_keyboardinput'), + "", //URL + { + shared: UI.getSetting('shared', true), + repeaterID: UI.getSetting('repeaterID', false), + credentials: { password: null } + }, + false // Not a primary display + ); + UI.rfb.addEventListener("connect", UI.connectFinished); + //UI.rfb.addEventListener("disconnect", UI.disconnectFinished); + UI.rfb.clipViewport = UI.getSetting('view_clip'); + UI.rfb.scaleViewport = UI.getSetting('resize', false, 'remote') === 'scale'; + UI.rfb.resizeSession = UI.getSetting('resize', false, 'remote') === 'remote'; + UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); + UI.rfb.dynamicQualityMin = parseInt(UI.getSetting('dynamic_quality_min')); + UI.rfb.dynamicQualityMax = parseInt(UI.getSetting('dynamic_quality_max')); + UI.rfb.jpegVideoQuality = parseInt(UI.getSetting('jpeg_video_quality')); + UI.rfb.webpVideoQuality = parseInt(UI.getSetting('webp_video_quality')); + UI.rfb.videoArea = parseInt(UI.getSetting('video_area')); + UI.rfb.videoTime = parseInt(UI.getSetting('video_time')); + UI.rfb.videoOutTime = parseInt(UI.getSetting('video_out_time')); + UI.rfb.videoScaling = parseInt(UI.getSetting('video_scaling')); + UI.rfb.treatLossless = parseInt(UI.getSetting('treat_lossless')); + UI.rfb.maxVideoResolutionX = parseInt(UI.getSetting('max_video_resolution_x')); + UI.rfb.maxVideoResolutionY = parseInt(UI.getSetting('max_video_resolution_y')); + UI.rfb.frameRate = parseInt(UI.getSetting('framerate')); + UI.rfb.compressionLevel = parseInt(UI.getSetting('compression')); + UI.rfb.showDotCursor = UI.getSetting('show_dot', true); + 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', true, true); + UI.rfb.clipboardDown = UI.getSetting('clipboard_down', true, true); + UI.rfb.clipboardSeamless = UI.getSetting('clipboard_seamless', true, true); + UI.rfb.keyboard.enableIME = UI.getSetting('enable_ime', true, false); + UI.rfb.clipboardBinary = supportsBinaryClipboard() && UI.rfb.clipboardSeamless; + UI.rfb.enableWebRTC = UI.getSetting('enable_webrtc', true, false); + UI.rfb.enableHiDpi = UI.getSetting('enable_hidpi', true, false); + UI.rfb.mouseButtonMapper = UI.initMouseButtonMapper(); + if (UI.rfb.videoQuality === 5) { + UI.rfb.enableQOI = true; + } + + if (UI.supportsBroadcastChannel) { + console.log('add event listener') + UI.controlChannel = new BroadcastChannel("registrationChannel"); + UI.controlChannel.addEventListener('message', UI.handleControlMessage) + } + + //attach this secondary display to the primary display + if (UI.screenID === null) { + const screen = UI.rfb.attachSecondaryDisplay(); + UI.screenID = screen.screenID + UI.screen = screen + } else { + console.log('else reattach screens') + console.log(UI.screen) + UI.rfb.reattachSecondaryDisplay(UI.screen); + } + document.querySelector('title').textContent = 'Display ' + UI.screenID + + + if (supportsBinaryClipboard()) { + // explicitly request permission to the clipboard + navigator.permissions.query({ name: "clipboard-read" }).then((result) => { Log.Debug('binary clipboard enabled') }); + } + }, + + handleControlMessage(event) { + switch (event.data.eventType) { + case 'identify': + UI.identify(event.data) + break; + case 'secondarydisconnected': + UI.updateVisualState('disconnected'); + break; + } + }, + + updateVisualState(state) { + document.documentElement.classList.remove("noVNC_connecting"); + 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()) + { + parent.postMessage({ action: 'connection_state', value: state}, '*' ); + } + + let connect_el = document.getElementById('noVNC_connect_dlg'); + + switch (state) { + case 'init': + break; + case 'connecting': + transitionElem.textContent = _("Connecting..."); + document.documentElement.classList.add("noVNC_connecting"); + break; + case 'connected': + document.documentElement.classList.add("noVNC_connected"); + if (!connect_el.classList.contains("noVNC_hidden")) { + connect_el.classList.add('noVNC_hidden'); + } + break; + case 'disconnecting': + transitionElem.textContent = _("Disconnecting..."); + document.documentElement.classList.add("noVNC_disconnecting"); + break; + case 'disconnected': + console.log('disconnected') + document.documentElement.classList.add("noVNC_disconnected"); + if (connect_el.classList.contains("noVNC_hidden")) { + connect_el.classList.remove('noVNC_hidden'); + } + UI.disconnect() + break; + case 'reconnecting': + transitionElem.textContent = _("Reconnecting..."); + document.documentElement.classList.add("noVNC_reconnecting"); + break; + default: + Log.Error("Invalid visual state: " + state); + UI.showStatus(_("Internal error"), 'error'); + return; + } + }, + + identify(data) { + UI.screens = data.screens + console.log('identify') + const screen = data.screens.find(el => el.id === UI.screenID) + if (screen) { + document.getElementById('noVNC_identify_monitor').innerHTML = screen.num + document.getElementById('noVNC_identify_monitor').classList.add("show") + document.querySelector('title').textContent = 'Display ' + screen.num + ' - ' + UI.screenID + setTimeout(() => { + document.getElementById('noVNC_identify_monitor').classList.remove("show") + }, 3500) + } + }, + + + 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') { + statusType = 'normal'; + } + + // Don't overwrite more severe visible statuses and never + // errors. Only shows the first error. + if (statusElem.classList.contains("noVNC_open")) { + if (statusElem.classList.contains("noVNC_status_error")) { + return; + } + if (statusElem.classList.contains("noVNC_status_warn") && + statusType === 'normal') { + return; + } + } + + clearTimeout(UI.statusTimeout); + + switch (statusType) { + case 'error': + statusElem.classList.remove("noVNC_status_warn"); + statusElem.classList.remove("noVNC_status_normal"); + statusElem.classList.add("noVNC_status_error"); + break; + case 'warning': + case 'warn': + statusElem.classList.remove("noVNC_status_error"); + statusElem.classList.remove("noVNC_status_normal"); + statusElem.classList.add("noVNC_status_warn"); + break; + case 'normal': + case 'info': + default: + statusElem.classList.remove("noVNC_status_error"); + statusElem.classList.remove("noVNC_status_warn"); + statusElem.classList.add("noVNC_status_normal"); + break; + } + + statusElem.textContent = text; + statusElem.classList.add("noVNC_open"); + + // If no time was specified, show the status for 1.5 seconds + if (typeof time === 'undefined') { + time = 1500; + } + + // Error messages do not timeout + if (statusType !== 'error') { + UI.statusTimeout = window.setTimeout(UI.hideStatus, time); + } + }, + + hideStatus() { + clearTimeout(UI.statusTimeout); + document.getElementById('noVNC_status').classList.remove("noVNC_open"); + }, + + disconnect() { + if (UI.rfb) { + UI.rfb.disconnect(); + if (UI.supportsBroadcastChannel) { + console.log('remove event listeners') + UI.controlChannel.removeEventListener('message', UI.handleControlMessage); + UI.rfb.removeEventListener("connect", UI.connectFinished); + } + } + }, + + connectFinished(e) { + UI.connected = true; + UI.inhibitReconnect = false; + + UI.showStatus("Secondary Screen Connected"); + UI.updateVisualState('connected'); + + // Do this last because it can only be used on rendered elements + UI.rfb.focus(); + }, + + initMouseButtonMapper() { + const mouseButtonMapper = new MouseButtonMapper(); + + const settings = WebUtil.readSetting("mouseButtonMapper"); + if (settings) { + mouseButtonMapper.load(settings); + return mouseButtonMapper; + } + + mouseButtonMapper.set(0, XVNC_BUTTONS.LEFT_BUTTON); + mouseButtonMapper.set(1, XVNC_BUTTONS.MIDDLE_BUTTON); + mouseButtonMapper.set(2, XVNC_BUTTONS.RIGHT_BUTTON); + mouseButtonMapper.set(3, XVNC_BUTTONS.BACK_BUTTON); + mouseButtonMapper.set(4, XVNC_BUTTONS.FORWARD_BUTTON); + WebUtil.writeSetting("mouseButtonMapper", mouseButtonMapper.dump()); + + return mouseButtonMapper; + }, + + updateLogging() { + WebUtil.initLogging(UI.getSetting('logging')); + }, + + // Initial page load read/initialization of settings + initSetting(name, defVal) { + // Check Query string followed by cookie + let val = WebUtil.getConfigVar(name); + if (val === null) { + val = WebUtil.readSetting(name, defVal); + } + WebUtil.setSetting(name, val); + return val; + }, + +} + +UI.prime(); + +export default UI; diff --git a/core/base64.js b/core/base64.js index e4b6db79..f910ed26 100644 --- a/core/base64.js +++ b/core/base64.js @@ -58,7 +58,7 @@ export default { /* Every four characters is 3 resulting numbers */ const resultLength = (dataLength >> 2) * 3 + Math.floor((dataLength % 4) / 1.5); - const result = new Array(resultLength); + const result = new Uint8Array(resultLength); // Convert one by one. diff --git a/core/display.js b/core/display.js index 1713314a..5fb7042c 100644 --- a/core/display.js +++ b/core/display.js @@ -11,9 +11,10 @@ import * as Log from './util/logging.js'; import Base64 from "./base64.js"; import { toSigned32bit } from './util/int.js'; import { isWindows } from './util/browser.js'; +import { uuidv4 } from './util/strings.js'; export default class Display { - constructor(target) { + constructor(target, isPrimaryDisplay) { Log.Debug(">> Display.constructor"); /* @@ -30,6 +31,7 @@ export default class Display { this._asyncFrameQueue = []; this._maxAsyncFrameQueue = 3; this._clearAsyncQueue(); + this._syncFrameQueue = []; this._flushing = false; @@ -56,7 +58,7 @@ export default class Display { this._targetCtx = this._target.getContext('2d'); // the visible canvas viewport (i.e. what actually gets seen) - this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height }; + //this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height }; Log.Debug("User Agent: " + navigator.userAgent); @@ -80,22 +82,57 @@ export default class Display { // ===== PROPERTIES ===== + this._maxScreens = 4; this._scale = 1.0; this._clipViewport = false; this._antiAliasing = 0; this._fps = 0; + this._isPrimaryDisplay = isPrimaryDisplay; + this._screenID = uuidv4(); + this._screens = [{ + screenID: this._screenID, + screenIndex: 0, + width: this._target.width, //client + height: this._target.height, //client + serverWidth: 0, //calculated + serverHeight: 0, //calculated + x: 0, + y: 0, + relativePosition: 0, //left, right, up, down relative to primary display + relativePositionX: 0, //offset relative to primary monitor, always 0 for primary + relativePositionY: 0, //offset relative to primary monitor, always 0 for primary + pixelRatio: window.devicePixelRatio, + containerHeight: this._target.parentNode.offsetHeight, + containerWidth: this._target.parentNode.offsetWidth, + channel: null + }]; // ===== EVENT HANDLERS ===== this.onflush = () => { }; // A flush request has finished - // Use requestAnimationFrame to write to canvas, to match display refresh rate - this._animationFrameID = window.requestAnimationFrame( () => { this._pushAsyncFrame(); }); + if (!this._isPrimaryDisplay) { + this._screens[0].channel = new BroadcastChannel(`screen_${this._screenID}_channel`); + this._screens[0].channel.addEventListener('message', this._handleSecondaryDisplayMessage.bind(this)); + } else { + //this._animationFrameID = window.requestAnimationFrame( () => { this._pushAsyncFrame(); }); + } Log.Debug("<< Display.constructor"); } // ===== PROPERTIES ===== + + get screens() { return this._screens; } + get screenId() { return this._screenID; } + get screenIndex() { + // A secondary screen should not have a screen index of 0, but it will be 0 until registration is complete + // returning a -1 lets the caller know the screen has not been registered yet + if (!this._isPrimaryDisplay && this._screens[0].screenIndex == 0) { + return -1; + } + return this._screens[0].screenIndex; + } get antiAliasing() { return this._antiAliasing; } set antiAliasing(value) { @@ -112,8 +149,8 @@ export default class Display { set clipViewport(viewport) { this._clipViewport = viewport; // May need to readjust the viewport dimensions - const vp = this._viewportLoc; - this.viewportChangeSize(vp.w, vp.h); + const vp = this._screens[0]; + this.viewportChangeSize(vp.width, vp.height); this.viewportChangePos(0, 0); } @@ -136,18 +173,207 @@ export default class Display { // ===== PUBLIC METHODS ===== + /* + Returns the screen index and relative coordinates given globally scoped coordinates + */ + getClientRelativeCoordinates(x, y) { + for (let i = 0; i < this._screens.length; i++) { + if ( + (x >= this._screens[i].x && x <= this._screens[i].x + this._screens[i].serverWidth) && + (y >= this._screens[i].y && y <= this._screens[i].y + this._screens[i].serverHeight) + ) + { + return { + "screenIndex": i, + "x": x - this._screens[i].x, + "y": y - this._screens[i].y + } + } + } + } + + /* + Returns coordinates that are server relative when multiple monitors are in use + */ + getServerRelativeCoordinates(screenIndex, x, y) { + if (screenIndex >= 0 && screenIndex < this._screens.length) { + x += this._screens[screenIndex].x; + y += this._screens[screenIndex].y; + } + + return [x, y]; + } + + getScreenSize(resolutionQuality, max_width, max_height, hiDpi, disableLimit) { + let data = { + screens: null, + serverWidth: 0, + serverHeight: 0 + } + + //recalculate primary display container size + this._screens[0].containerHeight = this._target.parentNode.offsetHeight; + this._screens[0].containerWidth = this._target.parentNode.offsetWidth; + this._screens[0].width = this._target.parentNode.offsetWidth; + this._screens[0].height = this._target.parentNode.offsetHeight; + this._screens[0].pixelRatio = window.devicePixelRatio; + //this._screens[0].width = this._target.width; + //this._screens[0].height = this._target.height; + + //calculate server-side resolution of each screen + for (let i=0; i 1280 && !disableLimit && resolutionQuality == 1) { + height = 1280 * (height/width); //keeping the aspect ratio of original resolution, shrink y to match x + width = 1280; + } + //hard coded 720p + else if (resolutionQuality == 0 && !disableLimit) { + width = 1280; + height = 720; + } + //force full resolution on a high DPI monitor where the OS is scaling + else if (hiDpi) { + width = width * this._screens[i].pixelRatio; + height = height * this._screens[i].pixelRatio; + scale = 1 / this._screens[i].pixelRatio; + } + //physically small device with high DPI + else if (this._antiAliasing === 0 && this._screens[i].pixelRatio > 1 && width < 1000 & width > 0) { + Log.Info('Device Pixel ratio: ' + this._screens[i].pixelRatio + ' Reported Resolution: ' + width + 'x' + height); + let targetDevicePixelRatio = 1.5; + if (this._screens[i].pixelRatio > 2) { targetDevicePixelRatio = 2; } + let scaledWidth = (width * this._screens[i].pixelRatio) * (1 / targetDevicePixelRatio); + let scaleRatio = scaledWidth / width; + width = width * scaleRatio; + height = height * scaleRatio; + scale = 1 / scaleRatio; + Log.Info('Small device with hDPI screen detected, auto scaling at ' + scaleRatio + ' to ' + width + 'x' + height); + } + + this._screens[i].serverWidth = width; + this._screens[i].serverHeight = height; + this._screens[i].scale = scale; + this._screens[i].x2 = this._screens[i].x + this._screens[i].serverWidth; + this._screens[i].y2 = this._screens[i].y + this._screens[i].serverHeight; + } + + for (let i = 0; i < this._screens.length; i++) { + data.serverWidth = Math.max(data.serverWidth, this._screens[i].x + this._screens[i].serverWidth); + data.serverHeight = Math.max(data.serverHeight, this._screens[i].y + this._screens[i].serverHeight); + } + + data.screens = this._screens; + + return data; + } + + applyScreenPlan(screenPlan) { + for (let i = 0; i < screenPlan.screens.length; i++) { + for (let z = 0; z < this._screens.length; z++) { + if (screenPlan.screens[i].screenID === this._screens[z].screenID) { + this._screens[z].x = screenPlan.screens[i].x; + this._screens[z].y = screenPlan.screens[i].y; + } + } + } + } + + addScreen(screenID, width, height, pixelRatio, containerHeight, containerWidth) { + if (!this._isPrimaryDisplay) { + throw new Error("Cannot add a screen to a secondary display."); + } + let screenIdx = -1; + + //Does the screen already exist? + for (let i = 0; i < this._screens.length; i++) { + if (this._screens[i].screenID === screenID) { + screenIdx = i; + } + } + + if (screenIdx > 0) { + //existing screen, update + const screen = this._screens[screenIdx]; + screen.width = width; + screen.height = height; + screen.containerHeight = containerHeight; + screen.containerWidth = containerWidth; + screen.pixelRatio = pixelRatio; + + } else { + //New Screen, add to far right until user repositions it + let x = 0; + for (let i = 0; i < this._screens.length; i++) { + x = Math.max(x, this._screens[i].x + this._screens[i].serverWidth); + } + + var new_screen = { + screenID: screenID, + screenIndex: this.screens.length, + width: width, //client + height: height, //client + serverWidth: 0, //calculated + serverHeight: 0, //calculated + x: x, + y: 0, + pixelRatio: pixelRatio, + containerHeight: containerHeight, + containerWidth: containerWidth, + channel: null, + scale: 0 + } + + new_screen.channel = new BroadcastChannel(`screen_${screenID}_channel`); + //new_screen.channel.message = this._handleSecondaryDisplayMessage().bind(this); + + this._screens.push(new_screen); + new_screen.channel.postMessage({ eventType: "registered", screenIndex: new_screen.screenIndex }); + } + } + + removeScreen(screenID) { + let removed = false; + if (this._isPrimaryDisplay) { + for (let i=1; i 0) { + this._screens[i].channel.postMessage({ eventType: "registered", screenIndex: i }); + } + } + return removed; + } else { + throw new Error("Secondary screens only allowed on primary display.") + } + } + viewportChangePos(deltaX, deltaY) { - const vp = this._viewportLoc; + const vp = this._screens[0]; deltaX = Math.floor(deltaX); deltaY = Math.floor(deltaY); if (!this._clipViewport) { - deltaX = -vp.w; // clamped later of out of bounds - deltaY = -vp.h; + deltaX = -vp.width; // clamped later of out of bounds + deltaY = -vp.height; } - const vx2 = vp.x + vp.w - 1; - const vy2 = vp.y + vp.h - 1; + const vx2 = vp.x + vp.width - 1; + const vy2 = vp.y + vp.height - 1; // Position change @@ -173,7 +399,7 @@ export default class Display { viewportChangeSize(width, height) { - if (!this._clipViewport || + if ((!this._clipViewport && this._screens.length === 1 ) || typeof(width) === "undefined" || typeof(height) === "undefined") { @@ -192,10 +418,10 @@ export default class Display { height = this._fbHeight; } - const vp = this._viewportLoc; - if (vp.w !== width || vp.h !== height) { - vp.w = width; - vp.h = height; + const vp = this._screens[0]; + if (vp.serverWidth !== width || vp.serverHeight !== height) { + vp.serverWidth = width; + vp.serverHeight = height; const canvas = this._target; canvas.width = width; @@ -213,14 +439,14 @@ export default class Display { if (this._scale === 0) { return 0; } - return toSigned32bit(x / this._scale + this._viewportLoc.x); + return toSigned32bit(x / this._scale + this._screens[0].x); } absY(y) { if (this._scale === 0) { return 0; } - return toSigned32bit(y / this._scale + this._viewportLoc.y); + return toSigned32bit(y / this._scale + this._screens[0].y); } resize(width, height) { @@ -231,6 +457,10 @@ export default class Display { const canvas = this._target; if (canvas == undefined) { return; } + if (this._screens.length > 0) { + width = this._screens[0].serverWidth; + height = this._screens[0].serverHeight; + } if (canvas.width !== width || canvas.height !== height) { // We have to save the canvas data since changing the size will clear it @@ -253,8 +483,8 @@ export default class Display { // Readjust the viewport as it may be incorrectly sized // and positioned - const vp = this._viewportLoc; - this.viewportChangeSize(vp.w, vp.h); + const vp = this._screens[0]; + this.viewportChangeSize(vp.serverWidth, vp.serverHeight); this.viewportChangePos(0, 0); } @@ -267,7 +497,8 @@ export default class Display { this._asyncRenderQPush({ 'type': 'flip', 'frame_id': frame_id, - 'rect_cnt': rect_cnt + 'rect_cnt': rect_cnt, + 'screenLocations': [ { screenIndex: 0, x: 0, y: 0 } ] }); } @@ -291,7 +522,7 @@ export default class Display { this._asyncFrameComplete(0, true); if (onflush_message) - this._flushing = true; + this.onflush(); } /* @@ -307,21 +538,22 @@ export default class Display { */ dispose() { clearInterval(this._frameStatsInterval); - cancelAnimationFrame(this._animationFrameID); this.clear(); } fillRect(x, y, width, height, color, frame_id, fromQueue) { if (!fromQueue) { - this._asyncRenderQPush({ - 'type': 'fill', - 'x': x, - 'y': y, - 'width': width, - 'height': height, - 'color': color, - 'frame_id': frame_id - }); + let rect = { + type: 'fill', + x: x, + y: y, + width: width, + height: height, + color: color, + frame_id: frame_id + } + this._processRectScreens(rect); + this._asyncRenderQPush(rect); } else { this._setFillColor(color); this._targetCtx.fillRect(x, y, width, height); @@ -330,7 +562,7 @@ export default class Display { copyImage(oldX, oldY, newX, newY, w, h, frame_id, fromQueue) { if (!fromQueue) { - this._asyncRenderQPush({ + let rect = { 'type': 'copy', 'oldX': oldX, 'oldY': oldY, @@ -339,7 +571,9 @@ export default class Display { 'width': w, 'height': h, 'frame_id': frame_id - }); + } + this._processRectScreens(rect); + this._asyncRenderQPush(rect); } else { // Due to this bug among others [1] we need to disable the image-smoothing to // avoid getting a blur effect when copying data. @@ -364,18 +598,31 @@ export default class Display { if ((width === 0) || (height === 0)) { return; } - const img = new Image(); - img.src = "data: " + mime + ";base64," + Base64.encode(arr); - - this._asyncRenderQPush({ + + let rect = { 'type': 'img', - 'img': img, + 'img': null, 'x': x, 'y': y, 'width': width, 'height': height, 'frame_id': frame_id - }); + } + this._processRectScreens(rect); + + if (rect.inPrimary) { + const img = new Image(); + img.src = "data: " + mime + ";base64," + Base64.encode(arr); + rect.img = img; + } else { + rect.type = "_img"; + } + if (rect.inSecondary) { + rect.mime = mime; + rect.src = "data: " + mime + ";base64," + Base64.encode(arr); + } + + this._asyncRenderQPush(rect); } transparentRect(x, y, width, height, img, frame_id) { @@ -393,12 +640,18 @@ export default class Display { 'height': height, 'frame_id': frame_id } + this._processRectScreens(rect); - let imageBmpPromise = createImageBitmap(img); - imageBmpPromise.then( function(img) { - rect.img = img; - rect.img.complete = true; - }.bind(rect) ); + if (rect.inPrimary) { + let imageBmpPromise = createImageBitmap(img); + imageBmpPromise.then( function(img) { + rect.img = img; + rect.img.complete = true; + }.bind(rect) ); + } + if (rect.inSecondary) { + rect.arr = img; + } this._asyncRenderQPush(rect); } @@ -410,7 +663,7 @@ export default class Display { // this probably isn't getting called *nearly* as much const newArr = new Uint8Array(width * height * 4); newArr.set(new Uint8Array(arr.buffer, 0, newArr.length)); - this._asyncRenderQPush({ + let rect = { 'type': 'blit', 'data': newArr, 'x': x, @@ -418,7 +671,9 @@ export default class Display { 'width': width, 'height': height, 'frame_id': frame_id - }); + } + this._processRectScreens(rect); + this._asyncRenderQPush(rect); } else { // NB(directxman12): arr must be an Type Array view let data = new Uint8ClampedArray(arr.buffer, @@ -431,7 +686,7 @@ export default class Display { blitQoi(x, y, width, height, arr, offset, frame_id, fromQueue) { if (!fromQueue) { - this._asyncRenderQPush({ + let rect = { 'type': 'blitQ', 'data': arr, 'x': x, @@ -439,7 +694,9 @@ export default class Display { 'width': width, 'height': height, 'frame_id': frame_id - }); + } + this._processRectScreens(rect); + this._asyncRenderQPush(rect); } else { this._targetCtx.putImageData(arr, x, y); } @@ -464,14 +721,14 @@ export default class Display { } else if (scaleRatio === 0) { - const vp = this._viewportLoc; + const vp = this._screens[0]; const targetAspectRatio = containerWidth / containerHeight; - const fbAspectRatio = vp.w / vp.h; + const fbAspectRatio = vp.width / vp.height; if (fbAspectRatio >= targetAspectRatio) { - scaleRatio = containerWidth / vp.w; + scaleRatio = containerWidth / vp.width; } else { - scaleRatio = containerHeight / vp.h; + scaleRatio = containerHeight / vp.height; } } @@ -480,6 +737,112 @@ export default class Display { // ===== PRIVATE METHODS ===== + _handleSecondaryDisplayMessage(event) { + if (!this._isPrimaryDisplay && event.data) { + + switch (event.data.eventType) { + case 'rect': + let rect = event.data.rect; + //overwrite screen locations when received on the secondary display + rect.screenLocations = [ rect.screenLocations[event.data.screenLocationIndex] ] + rect.screenLocations[0].screenIndex = 0; + switch (rect.type) { + case 'img': + case '_img': + rect.img = new Image(); + rect.img.src = rect.src; + rect.type = 'img'; + break; + case 'transparent': + let imageBmpPromise = createImageBitmap(rect.arr); + imageBmpPromise.then(function(rect, img) { + rect.img.complete = true; + }).bind(this, rect); + break; + } + this._syncFrameQueue.push(rect); + break; + case 'frameComplete': + window.requestAnimationFrame( () => { this._pushSyncRects(); }); + break; + case 'registered': + if (!this._isPrimaryDisplay) { + this._screens[0].screenIndex = event.data.screenIndex; + Log.Error(`Screen with index (${event.data.screenIndex}) successfully registered with the primary display.`); + } + break; + } + } + } + + _pushSyncRects() { + whileLoop: + while (this._syncFrameQueue.length > 0) { + const a = this._syncFrameQueue[0]; + const pos = a.screenLocations[0]; + switch (a.type) { + case 'copy': + this.copyImage(pos.oldX, pos.oldY, pos.x, pos.y, a.width, a.height, a.frame_id, true); + break; + case 'fill': + this.fillRect(pos.x, pos.y, a.width, a.height, a.color, a.frame_id, true); + break; + case 'blit': + this.blitImage(pos.x, pos.y, a.width, a.height, a.data, 0, a.frame_id, true); + break; + case 'blitQ': + this.blitQoi(pos.x, pos.y, a.width, a.height, a.data, 0, a.frame_id, true); + break; + case 'img': + if (a.img.complete) { + this.drawImage(a.img, pos.x, pos.y, a.width, a.height); + } else { + if (this._syncFrameQueue.length > 1000) { + this._syncFrameQueue.shift(); + this._droppedRects++; + } else { + break whileLoop; + } + } + break; + case 'transparent': + if (a.img.complete) { + this.drawImage(a.img, pos.x, pos.y, a.width, a.height); + } else { + if (this._syncFrameQueue.length > 1000) { + this._syncFrameQueue.shift(); + this._droppedRects++; + } else { + break whileLoop; + } + } + break; + default: + Log.Warn(`Unknown rect type: ${rect}`); + } + this._syncFrameQueue.shift(); + } + + if (this._syncFrameQueue.length > 0) { + window.requestAnimationFrame( () => { this._pushSyncRects(); }); + } + } + + _flushRectsScreen(screenIndex) { + for (let i=0; i= 0) { if (rect.type == "flip") { //flip rect contains the rect count for the frame if (this._asyncFrameQueue[frameIx][1] !== 0) { Log.Warn("Redundant flip rect, current rect_cnt: " + this._asyncFrameQueue[frameIx][1] + ", new rect_cnt: " + rect.rect_cnt ); } - this._asyncFrameQueue[frameIx][1] = rect.rect_cnt; + this._asyncFrameQueue[frameIx][1] += rect.rect_cnt; if (rect.rect_cnt == 0) { Log.Warn("Invalid rect count"); } } - if (this._asyncFrameQueue[frameIx][1] == this._asyncFrameQueue[frameIx][2].length) { + if (this._asyncFrameQueue[frameIx][1] > 0 && this._asyncFrameQueue[frameIx][2].length >= this._asyncFrameQueue[frameIx][1]) { //frame is complete this._asyncFrameComplete(frameIx); } @@ -527,11 +895,21 @@ export default class Display { if (rect.type == "flip") { this._lateFlipRect++; } return; } else if (rect.frame_id > newestFrameID) { - //frame is newer than any frame in the queue, drop old frames - this._asyncFrameQueue.shift(); + //frame is newer than any frame in the queue, drop old frame + if (this._asyncFrameQueue[0][3] == true) { + Log.Warn("Forced frame to canvas"); + this._pushAsyncFrame(true); + this._droppedFrames += (rect.frame_id - (newestFrameID + 1)); + this._forcedFrameCnt++; + } else { + Log.Warn("Old frame dropped"); + this._asyncFrameQueue.shift(); + this._droppedFrames += (rect.frame_id - newestFrameID); + } + let rect_cnt = ((rect.type == "flip") ? rect.rect_cnt : 0); this._asyncFrameQueue.push([ rect.frame_id, rect_cnt, [ rect ], (rect_cnt == 1), 0, 0 ]); - this._droppedFrames++; + } } @@ -554,6 +932,10 @@ export default class Display { If marked force, unloaded images will be skipped and the frame will be marked complete and ready for rendering */ _asyncFrameComplete(frameIx, force=false) { + if (frameIx >= this._asyncFrameQueue.length) { + return; + } + let currentFrameRectIx = this._asyncFrameQueue[frameIx][4]; if (force) { @@ -566,10 +948,13 @@ export default class Display { } } while (currentFrameRectIx < this._asyncFrameQueue[frameIx][2].length) { - if (this._asyncFrameQueue[frameIx][2][currentFrameRectIx].type == 'img' && !this._asyncFrameQueue[frameIx][2][currentFrameRectIx].img.complete) { - this._asyncFrameQueue[frameIx][2][currentFrameRectIx].type = 'skip'; - this._droppedRects++; + if (this._asyncFrameQueue[frameIx][2][currentFrameRectIx].type == 'img') { + if (this._asyncFrameQueue[frameIx][2][currentFrameRectIx].img && !this._asyncFrameQueue[frameIx][2][currentFrameRectIx].img.complete) { + this._asyncFrameQueue[frameIx][2][currentFrameRectIx].type = 'skip'; + this._droppedRects++; + } } + currentFrameRectIx++; } } else { @@ -587,6 +972,12 @@ export default class Display { } this._asyncFrameQueue[frameIx][4] = currentFrameRectIx; this._asyncFrameQueue[frameIx][3] = true; + + if (force && frameIx == 0) { + this._pushAsyncFrame(true); + } else { + window.requestAnimationFrame( () => { this._pushAsyncFrame(); }); + } } /* @@ -594,45 +985,77 @@ export default class Display { */ _pushAsyncFrame(force=false) { if (this._asyncFrameQueue[0][3] || force) { - let frame = this._asyncFrameQueue.shift()[2]; + let frame = this._asyncFrameQueue[0][2]; + let frameId = this._asyncFrameQueue.shift()[0]; if (this._asyncFrameQueue.length < this._maxAsyncFrameQueue) { this._asyncFrameQueue.push([ 0, 0, [], false, 0, 0 ]); } let transparent_rects = []; + let secondaryScreenRects = 0; //render the selected frame for (let i = 0; i < frame.length; i++) { const a = frame[i]; - switch (a.type) { - case 'copy': - this.copyImage(a.oldX, a.oldY, a.x, a.y, a.width, a.height, a.frame_id, true); - break; - case 'fill': - this.fillRect(a.x, a.y, a.width, a.height, a.color, a.frame_id, true); - break; - case 'blit': - this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, a.frame_id, true); - break; - case 'blitQ': - this.blitQoi(a.x, a.y, a.width, a.height, a.data, 0, a.frame_id, true); - break; - case 'img': - this.drawImage(a.img, a.x, a.y, a.width, a.height); - break; - case 'transparent': - transparent_rects.push(a); - break; + + for (let sI = 0; sI < a.screenLocations.length; sI++) { + let screenLocation = a.screenLocations[sI]; + if (screenLocation.screenIndex == 0) { + switch (a.type) { + case 'copy': + this.copyImage(screenLocation.oldX, screenLocation.oldY, screenLocation.x, screenLocation.y, a.width, a.height, a.frame_id, true); + break; + case 'fill': + this.fillRect(screenLocation.x, screenLocation.y, a.width, a.height, a.color, a.frame_id, true); + break; + case 'blit': + this.blitImage(screenLocation.x, screenLocation.y, a.width, a.height, a.data, 0, a.frame_id, true); + break; + case 'blitQ': + this.blitQoi(screenLocation.x, screenLocation.y, a.width, a.height, a.data, 0, a.frame_id, true); + break; + case 'img': + this.drawImage(a.img, screenLocation.x, screenLocation.y, a.width, a.height); + break; + case 'transparent': + transparent_rects.push(a); + break; + } + } else { + if (a.img) { + a.img = null; + } + + if (a.type !== 'flip') { + secondaryScreenRects++; + this._screens[screenLocation.screenIndex].channel.postMessage({ eventType: 'rect', rect: a, screenLocationIndex: sI }); + } + } } } //rects with transparency get applied last for (let i = 0; i < transparent_rects.length; i++) { const a = transparent_rects[i]; - - if (a.img) { - this.drawImage(a.img, a.x, a.y, a.width, a.height); + let screenIndexes = this._getRectScreenIndexes(a); + + for (let sI = 0; sI < screenLocations.length; sI++) { + let screenLocation = a.screenLocations[sI]; + if (sI == 0) { + if (a.img) { + this.drawImage(a.img, a.x, a.y, a.width, a.height); + } + } else { + secondaryScreenRects++; + this._screens[screenLocation.screenIndex].channel.postMessage({ eventType: 'rect', rect: a, screenLocationIndex: sI }); + } + } + } + + if (secondaryScreenRects > 0) { + for (let i = 1; i < this.screens.length; i++) { + this._screens[i].channel.postMessage({ eventType: 'frameComplete', frameId: frameId, rectCnt: secondaryScreenRects }); } } @@ -650,22 +1073,51 @@ export default class Display { this._pushAsyncFrame(true); } } + } - if (!force) { - window.requestAnimationFrame( () => { this._pushAsyncFrame(); }); + _processRectScreens(rect) { + + //find which screen this rect belongs to and adjust its x and y to be relative to the destination + let indexes = []; + rect.inPrimary = false; + rect.inSecondary = false; + for (let i=0; i < this._screens.length; i++) { + let screen = this._screens[i]; + + if ( + !((rect.x > screen.x2 || screen.x > (rect.x + rect.width)) && (rect.y > screen.y2 || screen.y > (rect.y + rect.height))) + ) { + let screenPosition = { + x: 0 - (screen.x - rect.x), //rect.x - screen.x, + y: 0 - (screen.y - rect.y), //rect.y - screen.y, + screenIndex: i + } + if (rect.type === 'copy') { + screenPosition.oldX = 0 - (screen.x - rect.oldX); //rect.oldX - screen.x; + screenPosition.oldY = 0 - (screen.y - rect.oldY); //rect.oldY - screen.y; + } + indexes.push(screenPosition); + if (i == 0) { + rect.inPrimary = true; + } else { + rect.inSecondary = true; + } + } } + + rect.screenLocations = indexes; } _rescale(factor) { this._scale = factor; - const vp = this._viewportLoc; + const vp = this._screens[0]; // NB(directxman12): If you set the width directly, or set the // style width to a number, the canvas is cleared. // However, if you set the style width to a string // ('NNNpx'), the canvas is scaled without clearing. - const width = factor * vp.w + 'px'; - const height = factor * vp.h + 'px'; + const width = factor * vp.serverWidth + 'px'; + const height = factor * vp.serverHeight + 'px'; if ((this._target.style.width !== width) || (this._target.style.height !== height)) { @@ -673,12 +1125,12 @@ export default class Display { this._target.style.height = height; } - Log.Info('Pixel Ratio: ' + window.devicePixelRatio + ', VNC Scale: ' + factor + 'VNC Res: ' + vp.w + 'x' + vp.h); + Log.Info('Pixel Ratio: ' + window.devicePixelRatio + ', VNC Scale: ' + factor + 'VNC Res: ' + vp.serverWidth + 'x' + vp.serverHeight); var pixR = Math.abs(Math.ceil(window.devicePixelRatio)); var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - if (this.antiAliasing === 2 || (this.antiAliasing === 0 && factor === 1 && this._target.style.imageRendering !== 'pixelated' && pixR === window.devicePixelRatio && vp.w > 0)) { + if (this.antiAliasing === 2 || (this.antiAliasing === 0 && factor === 1 && this._target.style.imageRendering !== 'pixelated' && pixR === window.devicePixelRatio && vp.width > 0)) { this._target.style.imageRendering = ((!isFirefox) ? 'pixelated' : 'crisp-edges' ); Log.Debug('Smoothing disabled'); } else if (this.antiAliasing === 1 || (this.antiAliasing === 0 && factor !== 1 && this._target.style.imageRendering !== 'auto')) { diff --git a/core/input/keyboard.js b/core/input/keyboard.js index db3c97ab..169c59b6 100644 --- a/core/input/keyboard.js +++ b/core/input/keyboard.js @@ -11,7 +11,6 @@ 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'; // @@ -46,6 +45,7 @@ export default class Keyboard { this._lastKeyboardInput = null; this._defaultKeyboardInputLen = 100; this._keyboardInputReset(); + this._translateShortcuts = true; } // ===== PUBLIC METHODS ===== @@ -56,6 +56,9 @@ export default class Keyboard { this.focus(); } + get translateShortcuts() { return this._translateShortcuts; } + set translateShortcuts(value) { this._translateShortcuts = value; } + // ===== PRIVATE METHODS ===== clearKeysDown(event) { @@ -319,7 +322,7 @@ export default class Keyboard { // Translate MacOs CMD based shortcuts to their CTRL based counterpart if ( browser.isMac() && - UI.rfb && UI.rfb.translateShortcuts && + this._translateShortcuts && code !== "MetaLeft" && code !== "MetaRight" && e.metaKey && !e.ctrlKey && !e.altKey ) { diff --git a/core/rfb.js b/core/rfb.js index b2ed01f5..f1e63c4f 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -10,7 +10,7 @@ import { toUnsigned32bit, toSigned32bit } from './util/int.js'; import * as Log from './util/logging.js'; -import { encodeUTF8, decodeUTF8 } from './util/strings.js'; +import { encodeUTF8, decodeUTF8, uuidv4 } from './util/strings.js'; import { hashUInt8Array } from './util/int.js'; import { dragThreshold, supportsCursorURIs, isTouchDevice, isWindows, isMac, isIOS } from './util/browser.js'; import { clientToElement } from './util/element.js'; @@ -76,11 +76,11 @@ const extendedClipboardActionNotify = 1 << 27; const extendedClipboardActionProvide = 1 << 28; export default class RFB extends EventTargetMixin { - constructor(target, touchInput, urlOrChannel, options) { + constructor(target, touchInput, urlOrChannel, options, isPrimaryDisplay) { if (!target) { throw new Error("Must specify target"); } - if (!urlOrChannel) { + if (!urlOrChannel && isPrimaryDisplay) { throw new Error("Must specify URL, WebSocket or RTCDataChannel"); } @@ -101,6 +101,7 @@ export default class RFB extends EventTargetMixin { this._shared = 'shared' in options ? !!options.shared : true; this._repeaterID = options.repeaterID || ''; this._wsProtocols = options.wsProtocols || ['binary']; + this._isPrimaryDisplay = (isPrimaryDisplay !== false); // Internal state this._rfbConnectionState = ''; @@ -122,7 +123,8 @@ export default class RFB extends EventTargetMixin { this._supportsContinuousUpdates = false; this._enabledContinuousUpdates = false; this._supportsSetDesktopSize = false; - this._screenID = 0; + this._screenID = uuidv4(); + this._screenIndex = 0; this._screenFlags = 0; this._qemuExtKeyEventSupported = false; @@ -205,6 +207,7 @@ export default class RFB extends EventTargetMixin { this._accumulatedWheelDeltaX = 0; this._accumulatedWheelDeltaY = 0; this.mouseButtonMapper = null; + this._mouseLastScreenIndex = -1; // Gesture state this._gestureLastTapTime = null; @@ -212,6 +215,19 @@ export default class RFB extends EventTargetMixin { this._gestureLastMagnitudeX = 0; this._gestureLastMagnitudeY = 0; + // Secondary Displays + this._secondaryDisplays = {}; + this._supportsBroadcastChannel = (typeof BroadcastChannel !== "undefined"); + if (this._supportsBroadcastChannel) { + this._controlChannel = new BroadcastChannel("registrationChannel"); + this._controlChannel.addEventListener('message', this._handleControlMessage.bind(this)); + Log.Debug("Attached to registrationChannel for secondary displays.") + + } + if (!this._isPrimaryDisplay) { + this._screenIndex = 2; + } + // Bound event handlers this._eventHandlers = { updateHiddenKeyboard: this._updateHiddenKeyboard.bind(this), @@ -223,6 +239,7 @@ export default class RFB extends EventTargetMixin { handleWheel: this._handleWheel.bind(this), handleGesture: this._handleGesture.bind(this), handleFocusChange: this._handleFocusChange.bind(this), + handleMouseOut: this._handleMouseOut.bind(this), }; // main setup @@ -262,7 +279,7 @@ export default class RFB extends EventTargetMixin { // NB: nothing that needs explicit teardown should be done // before this point, since this can throw an exception try { - this._display = new Display(this._canvas); + this._display = new Display(this._canvas, this._isPrimaryDisplay); } catch (exc) { Log.Error("Display exception: " + exc); throw exc; @@ -283,68 +300,13 @@ export default class RFB extends EventTargetMixin { this._gestures = new GestureHandler(); - this._sock = new Websock(); - this._sock.on('message', () => { - this._handleMessage(); - }); - this._sock.on('open', () => { - if ((this._rfbConnectionState === 'connecting') && - (this._rfbInitState === '')) { - this._rfbInitState = 'ProtocolVersion'; - Log.Debug("Starting VNC handshake"); - } else { - this._fail("Unexpected server connection while " + - this._rfbConnectionState); - } - }); - this._sock.on('close', (e) => { - Log.Debug("WebSocket on-close event"); - let msg = ""; - if (e.code) { - msg = "(code: " + e.code; - if (e.reason) { - msg += ", reason: " + e.reason; - } - msg += ")"; - } - switch (this._rfbConnectionState) { - case 'connecting': - this._fail("Connection closed " + msg); - break; - case 'connected': - // Handle disconnects that were initiated server-side - this._updateConnectionState('disconnecting'); - this._updateConnectionState('disconnected'); - break; - case 'disconnecting': - // Normal disconnection path - this._updateConnectionState('disconnected'); - break; - case 'disconnected': - this._fail("Unexpected server disconnect " + - "when already disconnected " + msg); - break; - default: - this._fail("Unexpected server disconnect before connecting " + - msg); - break; - } - this._sock.off('close'); - // Delete reference to raw channel to allow cleanup. - this._rawChannel = null; - }); - this._sock.on('error', e => Log.Warn("WebSocket on-error event")); - - // Slight delay of the actual connection so that the caller has - // time to set up callbacks - setTimeout(this._updateConnectionState.bind(this, 'connecting')); + if (this._isPrimaryDisplay) { + this._setupWebSocket(); + } Log.Debug("<< RFB.constructor"); // ===== PROPERTIES ===== - - - this.dragViewport = false; this.focusOnClick = true; this.lastActiveAt = Date.now(); @@ -367,6 +329,11 @@ export default class RFB extends EventTargetMixin { // ===== PROPERTIES ===== + get translateShortcuts() { return this._keyboard.translateShortcuts; } + set translateShortcuts(value) { + this._keyboard.translateShortcuts = value; + } + get pointerLock() { return this._pointerLock; } set pointerLock(value) { if (!this._pointerLock) { @@ -762,11 +729,87 @@ export default class RFB extends EventTargetMixin { if (value !== this._hiDpi) { this._hiDpi = value; this._requestRemoteResize(); + if (this._display.screens.length > 1) { + //force secondary displays to re-register and thus apply new hdpi setting + this._proxyRFBMessage('forceResize', [ value ]); + } } } // ===== PUBLIC METHODS ===== + attachSecondaryDisplay() { + this._updateConnectionState('connecting'); + const screen = this._registerSecondaryDisplay(); + this._updateConnectionState('connected'); + return screen + } + + reattachSecondaryDisplay(screen) { + this._updateConnectionState('connecting'); + this._registerSecondaryDisplay(screen); + this._updateConnectionState('connected'); + return screen + } + + + applyScreenPlan(screenPlan) { + if (this._isPrimaryDisplay) { + let fullPlan = this._screenSize(); + + //check plan for validity + let minX = Number.MAX_SAFE_INTEGER, minY = Number.MAX_SAFE_INTEGER; + let numScreensFound = 0; + + for (let i = 0; i < screenPlan.screens.length; i++) { + minX = Math.min(minX, screenPlan.screens[i].x); + minY = Math.min(minY, screenPlan.screens[i].y); + for (let z = 0; z < fullPlan.screens.length; z++) { + if (screenPlan.screens[i].screenID == fullPlan.screens[z].screenID) { + numScreensFound++; + } + } + } + if (minX !== 0 || minY !== 0) { + throw new Error("Screen plan invalid, improper coordinates provided."); + } + if (numScreensFound > fullPlan.screens.length) { + throw new Error("Screen plan contained more screens then there are registered.") + } else if (numScreensFound < fullPlan.screens.length) { + throw new Error("Screen plan contained fewer screens then there are registered.") + } + + this._display.applyScreenPlan(screenPlan); + const size = this._screenSize(); + RFB.messages.setDesktopSize(this._sock, size, this._screenFlags); + this._updateContinuousUpdates(); + } + } + + getScreenPlan() { + let fullPlan = this._screenSize(); + let sanitizedPlan = { + screens: [], + serverWidth: fullPlan.serverWidth, + serverHeight: fullPlan.serverHeight + }; + + for (let i=0; i < fullPlan.screens.length; i++) { + sanitizedPlan.screens.push( + { + screenID: fullPlan.screens[i].screenID, + serverWidth: fullPlan.screens[i].serverWidth, + serverHeight: fullPlan.screens[i].serverHeight, + x: fullPlan.screens[i].x, + y: fullPlan.screens[i].y, + pixelRatio: fullPlan.screens[i].pixelRatio + } + ) + } + + return sanitizedPlan; + } + /* This function must be called after changing any properties that effect rendering quality */ @@ -774,7 +817,9 @@ export default class RFB extends EventTargetMixin { if (this._rfbConnectionState === 'connected') { if (this._pendingApplyVideoRes) { - RFB.messages.setMaxVideoResolution(this._sock, this._maxVideoResolutionX, this._maxVideoResolutionY); + if (this._isPrimaryDisplay){ + RFB.messages.setMaxVideoResolution(this._sock, this._maxVideoResolutionX, this._maxVideoResolutionY); + } } if (this._pendingApplyResolutionChange) { @@ -793,15 +838,16 @@ export default class RFB extends EventTargetMixin { } disconnect() { - this._updateConnectionState('disconnecting'); - this._sock.off('error'); - this._sock.off('message'); - this._sock.off('open'); - } - - sendCredentials(creds) { - this._rfbCredentials = creds; - setTimeout(this._initMsg.bind(this), 0); + if (this._isPrimaryDisplay) { + this._updateConnectionState('disconnecting'); + this._sock.off('error'); + this._sock.off('message'); + this._sock.off('open'); + this._proxyRFBMessage('disconnect'); + } else { + this._updateConnectionState('disconnecting'); + this._unregisterSecondaryDisplay(); + } } sendCtrlAltDel() { @@ -851,13 +897,21 @@ export default class RFB extends EventTargetMixin { Log.Info("Sending key (" + (down ? "down" : "up") + "): keysym " + keysym + ", scancode " + scancode); - RFB.messages.QEMUExtendedKeyEvent(this._sock, keysym, down, scancode); + if (this._isPrimaryDisplay) { + RFB.messages.QEMUExtendedKeyEvent(this._sock, keysym, down, scancode); + } else { + this._proxyRFBMessage('QEMUExtendedKeyEvent', [ keysym, down, scancode ]) + } } else { if (!keysym) { return; } Log.Info("Sending keysym (" + (down ? "down" : "up") + "): " + keysym); - RFB.messages.keyEvent(this._sock, keysym, down ? 1 : 0); + if (this._isPrimaryDisplay) { + RFB.messages.keyEvent(this._sock, keysym, down ? 1 : 0); + } else { + this._proxyRFBMessage('keyEvent', [ keysym, down ? 1 : 0 ]) + } } } @@ -909,7 +963,12 @@ export default class RFB extends EventTargetMixin { let mimes = [ 'text/plain' ]; dataset.push(data); - RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes); + if (this._isPrimaryDisplay) { + RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes); + } else { + this._proxyRFBMessage('sendBinaryClipboard', [ dataset, mimes ]); + } + } async clipboardPasteDataFrom(clipdata) { @@ -948,7 +1007,7 @@ export default class RFB extends EventTargetMixin { continue; } - mimes.push(mime); + mimes.push(mime); dataset.push(data); Log.Debug('Sending mime type: ' + mime); break; @@ -973,23 +1032,33 @@ export default class RFB extends EventTargetMixin { if (dataset.length > 0) { - RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes); + if (this._isPrimaryDisplay) { + RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes); + } else { + this._proxyRFBMessage('sendBinaryClipboard', [ dataset, mimes ]); + } } } requestBottleneckStats() { - RFB.messages.requestStats(this._sock); + if (this._isPrimaryDisplay) { + RFB.messages.requestStats(this._sock); + } } subscribeUnixRelay(name, processRelayFn) { - this._unixRelays = this._unixRelays || {}; - this._unixRelays[name] = processRelayFn; - RFB.messages.sendSubscribeUnixRelay(this._sock, name); + if (this._isPrimaryDisplay){ + this._unixRelays = this._unixRelays || {}; + this._unixRelays[name] = processRelayFn; + RFB.messages.sendSubscribeUnixRelay(this._sock, name); + } } sendUnixRelayData(name, payload) { - RFB.messages.sendUnixRelay(this._sock, name, payload); + if (this._isPrimaryDisplay) { + RFB.messages.sendUnixRelay(this._sock, name, payload); + } } // ===== PRIVATE METHODS ===== @@ -1003,10 +1072,68 @@ export default class RFB extends EventTargetMixin { this._transitConnectionState = value; } + _setupWebSocket() { + this._sock = new Websock(); + this._sock.on('message', () => { + this._handleMessage(); + }); + this._sock.on('open', () => { + if ((this._rfbConnectionState === 'connecting') && + (this._rfbInitState === '')) { + this._rfbInitState = 'ProtocolVersion'; + Log.Debug("Starting VNC handshake"); + } else { + this._fail("Unexpected server connection while " + + this._rfbConnectionState); + } + }); + this._sock.on('close', (e) => { + Log.Debug("WebSocket on-close event"); + let msg = ""; + if (e.code) { + msg = "(code: " + e.code; + if (e.reason) { + msg += ", reason: " + e.reason; + } + msg += ")"; + } + switch (this._rfbConnectionState) { + case 'connecting': + this._fail("Connection closed " + msg); + break; + case 'connected': + // Handle disconnects that were initiated server-side + this._updateConnectionState('disconnecting'); + this._updateConnectionState('disconnected'); + break; + case 'disconnecting': + // Normal disconnection path + this._updateConnectionState('disconnected'); + break; + case 'disconnected': + this._fail("Unexpected server disconnect " + + "when already disconnected " + msg); + break; + default: + this._fail("Unexpected server disconnect before connecting " + + msg); + break; + } + this._sock.off('close'); + // Delete reference to raw channel to allow cleanup. + this._rawChannel = null; + }); + this._sock.on('error', e => Log.Warn("WebSocket on-error event")); + + // Slight delay of the actual connection so that the caller has + // time to set up callbacks + setTimeout(this._updateConnectionState.bind(this, 'connecting')); + } + _connect() { Log.Debug(">> RFB.connect"); - if (this._url) { + if (this._url && this._isPrimaryDisplay) { try { Log.Info(`connecting to ${this._url}`); this._sock.open(this._url, this._wsProtocols); @@ -1018,7 +1145,7 @@ export default class RFB extends EventTargetMixin { this._fail("Error when opening socket (" + e + ")"); } } - } else { + } else if (this._isPrimaryDisplay) { try { Log.Info(`attaching ${this._rawChannel} to Websock`); this._sock.attach(this._rawChannel); @@ -1046,6 +1173,9 @@ export default class RFB extends EventTargetMixin { window.addEventListener("focus", this._eventHandlers.handleFocusChange); window.addEventListener("blur", this._eventHandlers.handleFocusChange); + //User cursor moves outside of the window + window.addEventListener("mouseover", this._eventHandlers.handleMouseOut); + // In order for the keyboard to not occlude the input being edited // we move the hidden input we use for triggering the keyboard to the last click // position which should trigger a page being moved down enough @@ -1085,7 +1215,7 @@ export default class RFB extends EventTargetMixin { this._resendClipboardNextUserDrivenEvent = true; // WebRTC UDP datachannel inits - if (typeof RTCPeerConnection !== 'undefined') { + if (typeof RTCPeerConnection !== 'undefined' && this._isPrimaryDisplay) { this._udpBuffer = new Map(); this._udpPeer = new RTCPeerConnection({ @@ -1190,7 +1320,7 @@ export default class RFB extends EventTargetMixin { } } - if (this._useUdp && typeof RTCPeerConnection !== 'undefined') { + if (this._useUdp && typeof RTCPeerConnection !== 'undefined' && this._isPrimaryDisplay) { setTimeout(function() { this._sendUdpUpgrade() }.bind(this), 3000); } @@ -1224,7 +1354,17 @@ export default class RFB extends EventTargetMixin { window.removeEventListener('focus', this._eventHandlers.handleFocusChange); this._keyboard.ungrab(); this._gestures.detach(); - this._sock.close(); + if (this._isPrimaryDisplay) { + this._sock.close(); + } else { + if (this._primaryDisplayChannel) { + this._primaryDisplayChannel.postMessage({eventType: 'unregister', screenID: this._screenID}) + this._primaryDisplayChannel.removeEventListener('message', this._handleSecondaryDisplayMessage); + this._primaryDisplayChannel.close(); + this._primaryDisplayChannel = null; + } + } + try { this._target.removeChild(this._screen); } catch (e) { @@ -1325,7 +1465,7 @@ export default class RFB extends EventTargetMixin { // When clipping is enabled, the screen is limited to // the size of the container. const size = this._screenSize(); - this._display.viewportChangeSize(size.w, size.h); + this._display.viewportChangeSize(size.screens[0].serverWidth, size.screens[0].serverHeight); this._fixScrollbars(); } } @@ -1335,7 +1475,7 @@ export default class RFB extends EventTargetMixin { this._display.scale = 1.0; } else { const size = this._screenSize(false); - this._display.autoscale(size.w, size.h, size.scale); + this._display.autoscale(size.screens[0].serverWidth, size.screens[0].serverHeight, size.screens[0].scale); } this._fixScrollbars(); } @@ -1346,60 +1486,29 @@ export default class RFB extends EventTargetMixin { clearTimeout(this._resizeTimeout); this._resizeTimeout = null; - if (!this._resizeSession || this._viewOnly || - !this._supportsSetDesktopSize) { - return; - } - const size = this._screenSize(); - RFB.messages.setDesktopSize(this._sock, - Math.floor(size.w), Math.floor(size.h), - this._screenID, this._screenFlags); + if (this._isPrimaryDisplay) { + if (!this._resizeSession || this._viewOnly || + !this._supportsSetDesktopSize) { + return; + } + const size = this._screenSize(); + RFB.messages.setDesktopSize(this._sock, size, this._screenFlags); - Log.Debug('Requested new desktop size: ' + - size.w + 'x' + size.h); + Log.Debug('Requested new desktop size: ' + + size.serverWidth + 'x' + size.serverHeight); + } else if (this._display.screenIndex > 0) { + //re-register the secondary display with new resolution + this._registerSecondaryDisplay(); + } + + if (this._display.screens.length > 1) { + this.dispatchEvent(new CustomEvent("screenregistered", {})); + } } // Gets the the size of the available screen _screenSize (limited) { - if (limited === undefined) { - limited = true; - } - var x = this.forcedResolutionX || this._screen.offsetWidth; - var y = this.forcedResolutionY || this._screen.offsetHeight; - var scale = 0; // 0=auto - try { - if (x > 1280 && limited && this.videoQuality == 1) { - var ratio = y / x; - Log.Debug(ratio); - x = 1280; - y = x * ratio; - } - else if (limited && this.videoQuality == 0){ - x = 1280; - y = 720; - } else if (this._hiDpi == true) { - x = x * window.devicePixelRatio; - y = y * window.devicePixelRatio; - scale = 1 / window.devicePixelRatio; - } else if (this._display.antiAliasing === 0 && window.devicePixelRatio > 1 && x < 1000 && x > 0) { - // small device with high resolution, browser is essentially zooming greater than 200% - Log.Info('Device Pixel ratio: ' + window.devicePixelRatio + ' Reported Resolution: ' + x + 'x' + y); - let targetDevicePixelRatio = 1.5; - if (window.devicePixelRatio > 2) { targetDevicePixelRatio = 2; } - let scaledWidth = (x * window.devicePixelRatio) * (1 / targetDevicePixelRatio); - let scaleRatio = scaledWidth / x; - x = x * scaleRatio; - y = y * scaleRatio; - scale = 1 / scaleRatio; - Log.Info('Small device with hDPI screen detected, auto scaling at ' + scaleRatio + ' to ' + x + 'x' + y); - } - } catch (err) { - Log.Debug(err); - } - - return { w: x, - h: y, - scale: scale }; + return this._display.getScreenSize(this.videoQuality, this.forcedResolutionX, this.forcedResolutionY, this._hiDpi, limited); } _fixScrollbars() { @@ -1469,6 +1578,10 @@ export default class RFB extends EventTargetMixin { } break; + case 'proxied': + //secondary display that needs to proxy messages through the broadcast channel + break; + default: Log.Error("Unknown connection state: " + state); return; @@ -1486,9 +1599,11 @@ export default class RFB extends EventTargetMixin { this._disconnTimer = null; // make sure we don't get a double event - this._sock.off('close'); + if (this._isPrimaryDisplay) { + this._sock.off('close'); + } } - + switch (state) { case 'connecting': this._connect(); @@ -1504,6 +1619,7 @@ export default class RFB extends EventTargetMixin { this._disconnTimer = setTimeout(() => { Log.Error("Disconnection timed out."); this._updateConnectionState('disconnected'); + this._proxyRFBMessage('secondarydisconnected') }, DISCONNECT_TIMEOUT * 1000); break; @@ -1550,6 +1666,146 @@ export default class RFB extends EventTargetMixin { { detail: { capabilities: this._capabilities } })); } + _proxyRFBMessage(messageType, data) { + let message = { + eventType: messageType, + args: data, + screenId: this._display.screenId, + screenIndex: this._display.screenIndex, + mouseLastScreenIndex: this._mouseLastScreenIndex, + } + this._controlChannel.postMessage(message); + } + + _handleControlMessage(event) { + if (this._isPrimaryDisplay) { + // Secondary to Primary screen message + switch (event.data.eventType) { + case 'register': + this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth); + const size = this._screenSize(); + RFB.messages.setDesktopSize(this._sock, size, this._screenFlags); + this._sendEncodings(); + this._updateContinuousUpdates(); + this.dispatchEvent(new CustomEvent("screenregistered", {})); + Log.Info(`Secondary monitor (${event.data.screenID}) has been registered.`); + break; + case 'reattach': + console.log('reattach message') + console.log(event.data) + this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth); + this.dispatchEvent(new CustomEvent("screenregistered", {})); + Log.Info(`Secondary monitor (${event.data.screenID}) has been reattached.`); + break; + case 'unregister': + if (this._display.removeScreen(event.data.screenID)) { + this.dispatchEvent(new CustomEvent("screenregistered", {})); + Log.Info(`Secondary monitor (${event.data.screenID}) has been removed.`); + const size = this._screenSize(); + RFB.messages.setDesktopSize(this._sock, size, this._screenFlags); + this._sendEncodings(); + this._updateContinuousUpdates(); + this.dispatchEvent(new CustomEvent("screenregistered", {})); + } else { + Log.Info(`Secondary monitor (${event.data.screenID}) not found.`); + } + break; + case 'pointerEvent': + let coords = this._display.getServerRelativeCoordinates(event.data.screenIndex, event.data.args[0], event.data.args[1]); + this._mouseLastScreenIndex = event.data.screenIndex; + event.data.args[0] = coords[0]; + event.data.args[1] = coords[1]; + RFB.messages.pointerEvent(this._sock, ...event.data.args); + break; + case 'keyEvent': + RFB.messages.keyEvent(this._sock, ...event.data.args); + break; + case 'sendBinaryClipboard': + RFB.messages.sendBinaryClipboard(this._sock, ...event.data.args); + break; + // The following are primary to secondary messages that should be ignored on the primary + case 'updateCursor': + break; + } + } else { + // Primary to secondary screen message + switch (event.data.eventType) { + case 'updateCursor': + if (event.data.mouseLastScreenIndex === this._display.screenIndex || this._mouseLastScreenIndex === -1) { + this._updateCursor(...event.data.args); + this._mouseLastScreenIndex = event.data.mouseLastScreenIndex; + } + break; + case 'disconnect': + this.disconnect(); + break; + case 'forceResize': + this._hiDpi = event.data.args[0]; + this._updateScale(); + this._requestRemoteResize(); + break; + } + } + + } + + _unregisterSecondaryDisplay() { + if (!this._isPrimaryDisplay){ + let message = { + eventType: 'unregister', + screenID: this._display.screenId + } + this._controlChannel.postMessage(message); + } + + } + + _registerSecondaryDisplay(currentScreen = false) { + if (!this._isPrimaryDisplay) { + //let screen = this._screenSize().screens[0]; + // + let size = this._screenSize(); + this._display.resize(size.screens[0].containerWidth, size.screens[0].containerHeight); + this._display.autoscale(size.screens[0].containerWidth, size.screens[0].containerHeight, size.screens[0].scale); + screen = this._screenSize().screens[0]; + + const registertype = (currentScreen) ? 'reattach' : 'register' + + let message = { + eventType: registertype, + screenID: screen.screenID, + width: screen.width, + height: screen.height, + x: currentScreen.x || 0, + y: currentScreen.y || 0, + pixelRatio: screen.pixelRatio, + containerWidth: screen.containerWidth, + containerHeight: screen.containerHeight, + channel: null + } + this._controlChannel.postMessage(message); + + if (!this._viewOnly) { this._keyboard.grab(); } + // return screen.screenID + return screen + } + + } + + identify(screens) { + let message = { + eventType: 'identify', + screens + } + this._controlChannel.postMessage(message); + } + + _handleSecondaryDisplayMessage(event) { + if (this._isPrimaryDisplay) { + + } + } + _handleMessage() { if (this._sock.rQlen === 0) { Log.Warn("handleMessage called on an empty receive queue"); @@ -1583,6 +1839,18 @@ export default class RFB extends EventTargetMixin { this.sendKey(keysym, code, down); } + _handleMouseOut(ev) { + if (ev.toElement !== null && ev.relatedTarget === null) { + //mouse was outside of the window and just came in, this is our chance to do things + + //Ensure the window was not moved to a different screen with a different pixel ratio + if (this._display.screens[0].pixelRatio !== window.devicePixelRatio) { + Log.Debug("Window moved to another screen with different pixel ratio, sending resize request."); + this._requestRemoteResize(); + } + } + } + _handleMouse(ev) { /* * We don't check connection status or viewOnly here as the @@ -1638,6 +1906,7 @@ export default class RFB extends EventTargetMixin { this._canvas); } + this._mouseLastScreenIndex = this._display.screenIndex; this._setLastActive(); const mappedButton = this.mouseButtonMapper.get(ev.button); switch (ev.type) { @@ -1666,6 +1935,9 @@ export default class RFB extends EventTargetMixin { false, xvncButtonToMask(mappedButton)); break; case 'mousemove': + //when there are multiple screens + //This window can get mouse move events when the cursor is outside of the window, if the mouse is down + //when the cursor crosses the threshold of the window this._handleMouseMove(pos.x, pos.y); break; } @@ -1787,19 +2059,22 @@ export default class RFB extends EventTargetMixin { 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); + if (this._isPrimaryDisplay){ + RFB.messages.pointerEvent(this._sock, rel_16_x, rel_16_y, mask); + } else { + this._proxyRFBMessage('pointerEvent', [ 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); + if (this._isPrimaryDisplay) { + RFB.messages.pointerEvent(this._sock, this._display.absX(x), this._display.absY(y), mask); + } else { + this._proxyRFBMessage('pointerEvent', [ this._display.absX(x), this._display.absY(y), mask ]); + } + } } @@ -1808,7 +2083,12 @@ export default class RFB extends EventTargetMixin { if (this._rfbConnectionState !== 'connected') { return; } if (this._viewOnly) { return; } // View only, skip mouse events - RFB.messages.pointerEvent(this._sock, this._display.absX(x), this._display.absY(y), 0, dX, dY); + if (this._isPrimaryDisplay){ + RFB.messages.pointerEvent(this._sock, this._display.absX(x), this._display.absY(y), 0, dX, dY); + } else { + this._proxyRFBMessage('pointerEvent', [ this._display.absX(x), this._display.absY(y), 0, dX, dY ]); + } + } _handleWheel(ev) { @@ -2585,7 +2865,10 @@ export default class RFB extends EventTargetMixin { const encs = []; // In preference order - encs.push(encodings.encodingCopyRect); + // Disable copyrect when using multiple displays + if (this._display.screens.length === 1) { + encs.push(encodings.encodingCopyRect); + } // Only supported with full depth support if (this._fbDepth == 24) { encs.push(encodings.encodingTight); @@ -3244,9 +3527,9 @@ export default class RFB extends EventTargetMixin { const payload = this._sock.rQshiftStr(len); if (status) { - console.log("Unix relay subscription succeeded"); + Log.Info("Unix relay subscription succeeded"); } else { - console.log("Unix relay subscription failed, " + payload); + Log.Warn("Unix relay subscription failed, " + payload); } } @@ -3570,7 +3853,7 @@ export default class RFB extends EventTargetMixin { for (let i = 0; i < numberOfScreens; i += 1) { // Save the id and flags of the first screen if (i === 0) { - this._screenID = this._sock.rQshiftBytes(4); // id + this._screenIndex = this._sock.rQshiftBytes(4); // id this._sock.rQskipBytes(2); // x-position this._sock.rQskipBytes(2); // y-position this._sock.rQskipBytes(2); // width @@ -3687,7 +3970,12 @@ export default class RFB extends EventTargetMixin { rgbaPixels: rgba, hotx: hotx, hoty: hoty, w: w, h: h, }; + this._refreshCursor(); + + if (this._isPrimaryDisplay) { + this._proxyRFBMessage('updateCursor', [ rgba, hotx, hoty, w, h ]); + } } _shouldShowDotCursor() { @@ -4061,40 +4349,49 @@ RFB.messages = { } }, - setDesktopSize(sock, width, height, id, flags) { + setDesktopSize(sock, size, flags) { const buff = sock._sQ; const offset = sock._sQlen; buff[offset] = 251; // msg-type buff[offset + 1] = 0; // padding - buff[offset + 2] = width >> 8; // width - buff[offset + 3] = width; - buff[offset + 4] = height >> 8; // height - buff[offset + 5] = height; + buff[offset + 2] = size.serverWidth >> 8; // width + buff[offset + 3] = size.serverWidth; + buff[offset + 4] = size.serverHeight >> 8; // height + buff[offset + 5] = size.serverHeight; - buff[offset + 6] = 1; // number-of-screens + buff[offset + 6] = size.screens.length; // number-of-screens buff[offset + 7] = 0; // padding - // screen array - buff[offset + 8] = id >> 24; // id - buff[offset + 9] = id >> 16; - buff[offset + 10] = id >> 8; - buff[offset + 11] = id; - buff[offset + 12] = 0; // x-position - buff[offset + 13] = 0; - buff[offset + 14] = 0; // y-position - buff[offset + 15] = 0; - buff[offset + 16] = width >> 8; // width - buff[offset + 17] = width; - buff[offset + 18] = height >> 8; // height - buff[offset + 19] = height; - buff[offset + 20] = flags >> 24; // flags - buff[offset + 21] = flags >> 16; - buff[offset + 22] = flags >> 8; - buff[offset + 23] = flags; + let i = 8; + for (let iS = 0; iS < size.screens.length; iS++) { + //screen id + buff[offset + i++] = iS >> 24; + buff[offset + i++] = iS >> 16; + buff[offset + i++] = iS >> 8; + buff[offset + i++] = iS; + //screen x position + buff[offset + i++] = size.screens[iS].x >> 8; + buff[offset + i++] = size.screens[iS].x; + //screen y position + buff[offset + i++] = size.screens[iS].y >> 8; + buff[offset + i++] = size.screens[iS].y; + //screen width + buff[offset + i++] = size.screens[iS].serverWidth >> 8; + buff[offset + i++] = size.screens[iS].serverWidth; + //screen height + buff[offset + i++] = size.screens[iS].serverHeight >> 8; + buff[offset + i++] = size.screens[iS].serverHeight; + //flags + buff[offset + i++] = flags >> 24; + buff[offset + i++] = flags >> 16; + buff[offset + i++] = flags >> 8; + buff[offset + i++] = flags; + } - sock._sQlen += 24; + sock._sQlen += i; sock.flush(); + }, setMaxVideoResolution(sock, width, height) { diff --git a/core/util/element.js b/core/util/element.js index 466a7453..5824bd5e 100644 --- a/core/util/element.js +++ b/core/util/element.js @@ -28,5 +28,21 @@ export function clientToElement(x, y, elem) { } else { pos.y = y - bounds.top; } + + //multiple KasmVNC screens, Window can still receive mouse events when cursor goes + //outside of the window if the mouse is down while the moving occurs + if (x > window.innerWidth) { + pos.x += (x - window.innerWidth); + } + else if (x < 0) { + pos.x = x + bounds.left; + } + if (y > window.innerHeight) { + pos.y += (y - window.innerHeight); + } + else if (y < 0) { + pos.y = y + bounds.top; + } + return pos; } diff --git a/core/util/strings.js b/core/util/strings.js index 3dd4b29f..4f6241b0 100644 --- a/core/util/strings.js +++ b/core/util/strings.js @@ -26,3 +26,9 @@ export function decodeUTF8(utf8string, allowLatin1=false) { export function encodeUTF8(DOMString) { return unescape(encodeURIComponent(DOMString)); } + +export function uuidv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); + } diff --git a/screen.html b/screen.html new file mode 100644 index 00000000..66ddd034 --- /dev/null +++ b/screen.html @@ -0,0 +1,110 @@ + + + + + + Additional Display + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KasmVNC encountered an error: + + + + + + + + + + + + + + + + + + + + + + + Additional Display + Click to connect this additional display + + + + + + + + + + + + + + 0 + \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index e1782646..474bc355 100644 --- a/tests/README.md +++ b/tests/README.md @@ -46,4 +46,6 @@ This table keeps track of performance of pre-defined recordings, defined in the | newyork.1 | 08233e6 | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 106 | False | 2446ms | | losangeles.1 | 08233e6 | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 106 | False | 2272ms | | newyork.1 | base64opt | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 106 | False | 2273ms | -| losangeles.1 | base64opt | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 106 | False | 1847ms | \ No newline at end of file +| losangeles.1 | base64opt | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 106 | False | 1847ms | +| newyork.1 | 4a6aa73 | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 119 | False | 2128ms | +| losangeles.1 | 4a6aa73 | Macbook M1 Pro, 32GB RAM | macOS 12.2 | Chrome 119 | False | 1766ms | \ No newline at end of file diff --git a/vnc.html b/vnc.html index d334d983..c6fa0a33 100644 --- a/vnc.html +++ b/vnc.html @@ -50,7 +50,7 @@ - + @@ -185,6 +185,15 @@ Fullscreen + + + + Displays + + Toggle Control Panel via Keystrokes - - - - Render Native Resolution - - Idle Timeout: @@ -520,7 +523,7 @@ - + @@ -537,6 +540,30 @@ + + + + Arrange Displays + Drag and drop to arrange displays, new monitors are added to the right hand side of the previous monitor. + + + + + + + Add Monitor + + + + Native Resolution + + Identify + + Done + + + + @@ -548,25 +575,6 @@ - - - - - - Username: - - - - Password: - - - - - - - - - @@ -603,5 +611,6 @@ + 0