From 419a3a70e956942d9cefef503d5586930ed7cae9 Mon Sep 17 00:00:00 2001 From: mattmcclaskey Date: Fri, 8 Sep 2023 13:12:28 -0400 Subject: [PATCH] WIP - multi monitor refactor --- README.md | 2 +- app/ui.js | 107 ++++--------- app/ui_screen.js | 40 +++++ core/display.js | 217 ++++++++++++++++++++++--- core/rfb.js | 371 ++++++++++++++++++++++++++++++------------- core/util/strings.js | 6 + screen.html | 95 +++++++++++ vnc.html | 31 ++-- 8 files changed, 640 insertions(+), 229 deletions(-) create mode 100644 app/ui_screen.js create mode 100644 screen.html 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/ui.js b/app/ui.js index 3d674856..bc1ed064 100644 --- a/app/ui.js +++ b/app/ui.js @@ -31,8 +31,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 } @@ -69,6 +69,8 @@ const UI = { reconnectCallback: null, reconnectPassword: null, + supportsBroadcastChannel: (typeof BroadcastChannel !== "undefined"), + prime() { return WebUtil.initSettings().then(() => { if (document.readyState === "interactive" || document.readyState === "complete") { @@ -128,14 +130,13 @@ const UI = { UI.addExtraKeysHandlers(); UI.addGamingHandlers(); UI.addMachineHandlers(); - UI.addConnectionControlHandlers(); UI.addClipboardHandlers(); UI.addSettingsHandlers(); + UI.addMultiMonitorAddHandler(); document.getElementById("noVNC_status") .addEventListener('click', UI.hideStatus); UI.openControlbar(); - // UI.updateVisualState('init'); @@ -472,21 +473,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 +574,13 @@ const UI = { window.addEventListener('msfullscreenchange', UI.updateFullscreenButton); }, + addMultiMonitorAddHandler() { + if (UI.supportsBroadcastChannel) { + UI.showControlInput("noVNC_addmonitor_button"); + UI.addClickHandle('noVNC_addmonitor_button', UI.addSecondaryMonitor); + } + }, + /* ------^------- * /EVENT HANDLERS * ============== @@ -676,8 +669,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 +1371,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); @@ -1746,57 +1740,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 +1810,20 @@ const UI = { UI.rfb.enableHiDpi = UI.getSetting('enable_hidpi'); }, +/* ------^------- + * /MULTI-MONITOR SUPPORT + * ==============*/ + + 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); + }, + + + /* ------^------- * /RESIZE * ============== diff --git a/app/ui_screen.js b/app/ui_screen.js new file mode 100644 index 00000000..9ba4ff9a --- /dev/null +++ b/app/ui_screen.js @@ -0,0 +1,40 @@ + + + + +const UI = { + connected: false, + + //Initial Loading of the UI + prime() { + + }, + + //Render default UI + start() { + window.addEventListener("beforeunload", (e) => { + if (UI.rfb) { + UI.disconnect(); + } + }); + + + UI.addDefaultHandlers(); + }, + + addDefaultHandlers() { + document.getElementById('noVNC_connect_button', UI.connect); + }, + + connect() { + + }, + + disconnect() { + + } +} + +UI.prime(); + +export default UI; \ No newline at end of file diff --git a/core/display.js b/core/display.js index 1713314a..8b9c43a6 100644 --- a/core/display.js +++ b/core/display.js @@ -11,6 +11,7 @@ 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) { @@ -56,7 +57,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); @@ -84,6 +85,21 @@ export default class Display { this._clipViewport = false; this._antiAliasing = 0; this._fps = 0; + this._screens = [{ + screenID: uuidv4(), + screenIndex: 0, + width: this._target.width, //client + height: this._target.height, //client + serverWidth: 0, //calculated + serverHeight: 0, //calculated + x: 0, + y: 0, + relativePosition: 0, + pixelRatio: window.devicePixelRatio, + containerHeight: this._target.parentNode.offsetHeight, + containerWidth: this._target.parentNode.offsetWidth, + channel: null + }]; // ===== EVENT HANDLERS ===== @@ -96,6 +112,8 @@ export default class Display { } // ===== PROPERTIES ===== + + get screens() { return this._screens; } get antiAliasing() { return this._antiAliasing; } set antiAliasing(value) { @@ -112,8 +130,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 +154,167 @@ export default class Display { // ===== PUBLIC METHODS ===== + getScreenSize(resolutionQuality, max_width, max_height, hiDpi, disableLimit) { + let data = { + screens: null, + serverWidth: 0, + serverHeight: 0, + clientWidth: 0, + clientHeight: 0 + } + + //recalculate primary display container size + this._screens[0].containerHeight = this._target.parentNode.offsetHeight; + this._screens[0].containerWidth = this._target.parentNode.offsetWidth; + + //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 / x; + 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 logic will only support monitors laid out side by side + //TODO: two vertically stacked monitors would require light refactoring here + data.serverWidth += width; + data.serverHeight = Math.max(data.serverHeight, height); + data.clientWidth += this._screens[i].width; + data.clientHeight = Math.max(data.clientHeight, this._screens[i].height); + this._screens[i].scale = scale; + } + + //calculate positions of monitors, this logic will only support two monitors side by side in either order + if (this._screens.length > 1) { + const primary_screen = this._screens[0]; + const secondary_screen = this._screens[1]; + switch (this._screens[1].relativePosition) { + case 0: + //primary screen is to left + total_width = secondary_screen.serverWidth + primary_screen.serverWidth; + total_height = Math.max(primary_screen.serverHeight, secondary_screen.serverHeight); + secondary_screen.x = primary_screen.serverWidth; + if (secondary_screen.serverHeight < primary_screen.serverHeight) { + if ((total_height - secondary_screen.serverHeight) > 1) { + secondary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2))) + } + } else { + secondary_screen.y = 0; + if ((total_height - secondary_screen.serverHeight) > 1) { + this._screens[0].y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2))) + } + } + break; + case 2: + //primary screen is to right + total_width = primary_screen.serverWidth + secondary_screen.serverWidth; + total_height = Math.max(primary_screen.serverHeight, secondary_screen.serverHeight); + this._screens[0].x = secondary_screen.serverWidth; + if (secondary_screen.serverHeight < primary_screen.serverHeight) { + if ((total_height - secondary_screen.serverHeight) > 1) { + secondary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2))) + } + } else { + secondary_screen.y = 0; + if ((total_height - secondary_screen.serverHeight) > 1) { + primary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2))) + } + } + break; + default: + //TODO: It would not be hard to support vertically stacked monitors + throw new Error("Unsupported screen orientation."); + } + } + + data.screens = structuredClone(this._screens); + + return data; + } + + addScreen(screenID, width, height, relativePosition, pixelRatio, containerHeight, containerWidth) { + //currently only support one secondary screen + if (this._screens.length > 1) { + this._screens[1].channel.close(); + this._screens.pop() + } + screenIdx = this.screens.length; + new_screen = { + screenID: screenID, + screenIndex: screenIdx, + width: width, //client + height: height, //client + serverWidth: 0, //calculated + serverHeight: 0, //calculated + x: 0, + y: 0, + relativePosition: relativePosition, + 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: screenIdx }); + } + + removeScreen(screenID) { + for (let i=1; i= targetAspectRatio) { - scaleRatio = containerWidth / vp.w; + scaleRatio = containerWidth / vp.width; } else { - scaleRatio = containerHeight / vp.h; + scaleRatio = containerHeight / vp.height; } } @@ -658,14 +825,14 @@ export default class Display { _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.width + 'px'; + const height = factor * vp.height + 'px'; if ((this._target.style.width !== width) || (this._target.style.height !== height)) { @@ -673,12 +840,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.width + 'x' + vp.height); 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/rfb.js b/core/rfb.js index b2ed01f5..bb6813a6 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,7 +76,7 @@ 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"); } @@ -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; @@ -212,6 +214,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.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), @@ -283,68 +298,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(); @@ -774,7 +734,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 +755,12 @@ 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._rfbConnectionState !== 'proxied') { + this._updateConnectionState('disconnecting'); + this._sock.off('error'); + this._sock.off('message'); + this._sock.off('open'); + } } sendCtrlAltDel() { @@ -851,13 +810,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 +876,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 +920,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 +945,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 +985,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,13 +1058,15 @@ 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); } catch (e) { this._fail("Error attaching channel (" + e + ")"); } + } else { + this._registerSecondaryDisplay(); } // Make our elements part of the page @@ -1085,7 +1127,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 +1232,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 +1266,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 +1377,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 +1387,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].containerWidth, size.screens[0].containerHeight, size.screens[0].scale); } this._fixScrollbars(); } @@ -1351,20 +1403,20 @@ export default class RFB extends EventTargetMixin { return; } const size = this._screenSize(); - RFB.messages.setDesktopSize(this._sock, - Math.floor(size.w), Math.floor(size.h), - this._screenID, this._screenFlags); + RFB.messages.setDesktopSize(this._sock, size, this._screenFlags); Log.Debug('Requested new desktop size: ' + - size.w + 'x' + size.h); + size.serverWidth + 'x' + size.serverHeight); } // Gets the the size of the available screen _screenSize (limited) { + return this._display.getScreenSize(this.videoQuality, this.forcedResolutionX, this.forcedResolutionY, this._hiDpi, limited); + if (limited === undefined) { limited = true; } - var x = this.forcedResolutionX || this._screen.offsetWidth; + var x = this.forcedResolutionX || this._screen.offsetWidth; var y = this.forcedResolutionY || this._screen.offsetHeight; var scale = 0; // 0=auto try { @@ -1469,6 +1521,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,7 +1542,9 @@ export default class RFB extends EventTargetMixin { this._disconnTimer = null; // make sure we don't get a double event - this._sock.off('close'); + if (this._rfbConnectionState !== 'proxied') { + this._sock.off('close'); + } } switch (state) { @@ -1550,6 +1608,62 @@ export default class RFB extends EventTargetMixin { { detail: { capabilities: this._capabilities } })); } + _registerSecondaryDisplay() { + this._primaryDisplayChannel = new BroadcastChannel(`screen_${this._screenID}_channel`); + this._primaryDisplayChannel.message = this._handleSecondaryDisplayMessage.bind(this); + const size = this._screenSize(); + + message = { + eventType: 'register', + screenID: this._screenID, + screenIndex: this._screenIndex, + width: size.w, + height: size.h, + x: 0, + y: 0, + relativePosition: 0, + pixelRatio: window.devicePixelRatio, + containerWidth: this._screen.offsetWidth, + containerHeight: this._screen.offsetWidth, + channel: null + } + this._controlChannel.postMessage(message) + } + + _proxyRFBMessage(messageType, data) { + message = { + messageType: messageType, + data: data + } + this._controlChannel.postMessage(message); + } + + _handleControlMessage(event) { + console.log(event); + + switch (event.eventType) { + case 'register': + this._display.addScreen(event.screenID, event.width, event.height, event.relativePosition, event.pixelRatio, event.containerHeight, event.containerWidth); + Log.Info(`Secondary monitor (${event.screenID}) has been registered.`); + break; + case 'unregister': + if (this._display.removeScreen(event.screenID)) { + Log.Info(`Secondary monitor (${event.screenID}) has been removed.`); + } else { + Log.Info(`Secondary monitor (${event.screenID}) not found.`); + } + } + + + } + + _handleSecondaryDisplayMessage(event) { + console.log("Message Received: " + event); + if (this._isPrimaryDisplay) { + + } + } + _handleMessage() { if (this._sock.rQlen === 0) { Log.Warn("handleMessage called on an empty receive queue"); @@ -1790,16 +1904,23 @@ export default class RFB extends EventTargetMixin { //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 +1929,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) { @@ -3570,7 +3696,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 @@ -4061,20 +4187,50 @@ 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 + 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 += i; + sock.flush(); + + /* // screen array buff[offset + 8] = id >> 24; // id buff[offset + 9] = id >> 16; @@ -4095,6 +4251,7 @@ RFB.messages = { sock._sQlen += 24; sock.flush(); + */ }, setMaxVideoResolution(sock, width, height) { 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..e0d3fb79 --- /dev/null +++ b/screen.html @@ -0,0 +1,95 @@ + + + + + + KasmVNC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
KasmVNC encountered an error:
+
+
+
+
+ + + +
+ + +
+
+
+
+ Connect +
+
+
+
+ + +
+ + +
+ \ No newline at end of file diff --git a/vnc.html b/vnc.html index d334d983..08d581fc 100644 --- a/vnc.html +++ b/vnc.html @@ -50,7 +50,7 @@ - + @@ -185,6 +185,14 @@ Fullscreen + +
+ + Add Monitor +
+
- -
-
-
    -
  • - - -
  • -
  • - - -
  • -
  • - -
  • -
-
-
-