diff options
-rw-r--r-- | app/styles/lite.css | 63 | ||||
-rw-r--r-- | app/ui.js | 9 | ||||
-rw-r--r-- | core/rfb.js | 78 | ||||
-rw-r--r-- | core/util/cursor.js | 19 | ||||
-rw-r--r-- | docs/API.md | 5 | ||||
-rw-r--r-- | docs/EMBEDDING.md | 3 | ||||
-rw-r--r-- | vnc.html | 4 | ||||
-rw-r--r-- | vnc_lite.html | 334 |
8 files changed, 244 insertions, 271 deletions
diff --git a/app/styles/lite.css b/app/styles/lite.css deleted file mode 100644 index 13e11c7..0000000 --- a/app/styles/lite.css +++ /dev/null @@ -1,63 +0,0 @@ -/* - * noVNC auto CSS - * Copyright (C) 2012 Joel Martin - * Copyright (C) 2017 Samuel Mannehed for Cendio AB - * noVNC is licensed under the MPL 2.0 (see LICENSE.txt) - * This file is licensed under the 2-Clause BSD license (see LICENSE.txt). - */ - -body { - margin:0; - background-color:#313131; - border-bottom-right-radius: 800px 600px; - height:100%; - display: flex; - flex-direction: column; -} - -html { - background-color:#494949; - height:100%; -} - -#noVNC_status_bar { - width: 100%; - display:flex; - justify-content: space-between; -} - -#noVNC_status { - color: #fff; - font: bold 12px Helvetica; - margin: auto; -} - -.noVNC_status_normal { - background: linear-gradient(#b2bdcd 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); -} - -.noVNC_status_error { - background: linear-gradient(#c83737 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); -} - -.noVNC_status_warn { - background: linear-gradient(#b4b41e 0%,#899cb3 49%,#7e93af 51%,#6e84a3 100%); -} - -.noNVC_shown { - display: inline; -} -.noVNC_hidden { - display: none; -} - -#noVNC_left_dummy_elem { - flex: 1; -} - -#noVNC_buttons { - padding: 1px; - flex: 1; - display: flex; - justify-content: flex-end; -} @@ -161,6 +161,7 @@ const UI = { UI.initSetting('resize', 'off'); UI.initSetting('shared', true); UI.initSetting('view_only', false); + UI.initSetting('show_dot', false); UI.initSetting('path', 'websockify'); UI.initSetting('repeaterID', ''); UI.initSetting('reconnect', false); @@ -347,6 +348,8 @@ const UI = { UI.addSettingChangeHandler('shared'); UI.addSettingChangeHandler('view_only'); UI.addSettingChangeHandler('view_only', UI.updateViewOnly); + UI.addSettingChangeHandler('show_dot'); + UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor); UI.addSettingChangeHandler('host'); UI.addSettingChangeHandler('port'); UI.addSettingChangeHandler('path'); @@ -1015,6 +1018,7 @@ const UI = { UI.rfb = new RFB(document.getElementById('noVNC_container'), url, { shared: UI.getSetting('shared'), + showDotCursor: UI.getSetting('show_dot'), repeaterID: UI.getSetting('repeaterID'), credentials: { password: password } }); UI.rfb.addEventListener("connect", UI.connectFinished); @@ -1583,6 +1587,11 @@ const UI = { UI.setMouseButton(1); //has it's own logic for hiding/showing }, + updateShowDotCursor() { + if (!UI.rfb) return; + UI.rfb.showDotCursor = UI.getSetting('show_dot'); + }, + updateLogging() { WebUtil.init_logging(UI.getSetting('logging')); }, diff --git a/core/rfb.js b/core/rfb.js index 42d682b..6d5b455 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -53,6 +53,7 @@ export default class RFB extends EventTargetMixin { this._rfb_credentials = options.credentials || {}; this._shared = 'shared' in options ? !!options.shared : true; this._repeaterID = options.repeaterID || ''; + this._showDotCursor = options.showDotCursor || false; // Internal state this._rfb_connection_state = ''; @@ -141,7 +142,19 @@ export default class RFB extends EventTargetMixin { this._canvas.tabIndex = -1; this._screen.appendChild(this._canvas); - this._cursor = new Cursor(); + // Cursor + this._cursor = new Cursor(); + + // XXX: TightVNC 2.8.11 sends no cursor at all until Windows changes + // it. Result: no cursor at all until a window border or an edit field + // is hit blindly. But there are also VNC servers that draw the cursor + // in the framebuffer and don't send the empty local cursor. There is + // no way to satisfy both sides. + // + // The spec is unclear on this "initial cursor" issue. Many other + // viewers (TigerVNC, RealVNC, Remmina) display an arrow as the + // initial cursor instead. + this._cursorImage = RFB.cursors.none; // populate decoder array with objects this._decoders[encodings.encodingRaw] = new RawDecoder(); @@ -285,6 +298,12 @@ export default class RFB extends EventTargetMixin { } } + get showDotCursor() { return this._showDotCursor; } + set showDotCursor(show) { + this._showDotCursor = show; + this._refreshCursor(); + } + // ===== PUBLIC METHODS ===== disconnect() { @@ -387,6 +406,7 @@ export default class RFB extends EventTargetMixin { this._target.appendChild(this._screen); this._cursor.attach(this._canvas); + this._refreshCursor(); // Monitor size changes of the screen // FIXME: Use ResizeObserver, or hidden overflow @@ -1645,6 +1665,44 @@ export default class RFB extends EventTargetMixin { RFB.messages.xvpOp(this._sock, ver, op); } + _updateCursor(rgba, hotx, hoty, w, h) { + this._cursorImage = { + rgbaPixels: rgba, + hotx: hotx, hoty: hoty, w: w, h: h, + }; + this._refreshCursor(); + } + + _shouldShowDotCursor() { + // Called when this._cursorImage is updated + if (!this._showDotCursor) { + // User does not want to see the dot, so... + return false; + } + + // The dot should not be shown if the cursor is already visible, + // i.e. contains at least one not-fully-transparent pixel. + // So iterate through all alpha bytes in rgba and stop at the + // first non-zero. + for (let i = 3; i < this._cursorImage.rgbaPixels.length; i += 4) { + if (this._cursorImage.rgbaPixels[i]) { + return false; + } + } + + // At this point, we know that the cursor is fully transparent, and + // the user wants to see the dot instead of this. + return true; + } + + _refreshCursor() { + const image = this._shouldShowDotCursor() ? RFB.cursors.dot : this._cursorImage; + this._cursor.change(image.rgbaPixels, + image.hotx, image.hoty, + image.w, image.h + ); + } + static genDES(password, challenge) { const passwd = []; for (let i = 0; i < password.length; i++) { @@ -1969,3 +2027,21 @@ RFB.messages = { sock.flush(); } }; + +RFB.cursors = { + none: { + rgbaPixels: new Uint8Array(), + w: 0, h: 0, + hotx: 0, hoty: 0, + }, + + dot: { + rgbaPixels: new Uint8Array([ + 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, + 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, + ]), + w: 3, h: 3, + hotx: 1, hoty: 1, + } +}; diff --git a/core/util/cursor.js b/core/util/cursor.js index 18aa7be..7997194 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -79,25 +79,12 @@ export default class Cursor { this._target = null; } - change(pixels, mask, hotx, hoty, w, h) { + change(rgba, hotx, hoty, w, h) { if ((w === 0) || (h === 0)) { this.clear(); return; } - let cur = [] - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - let idx = y * Math.ceil(w / 8) + Math.floor(x / 8); - let alpha = (mask[idx] << (x % 8)) & 0x80 ? 255 : 0; - idx = ((w * y) + x) * 4; - cur.push(pixels[idx + 2]); // red - cur.push(pixels[idx + 1]); // green - cur.push(pixels[idx]); // blue - cur.push(alpha); // alpha - } - } - this._position.x = this._position.x + this._hotSpot.x - hotx; this._position.y = this._position.y + this._hotSpot.y - hoty; this._hotSpot.x = hotx; @@ -111,10 +98,10 @@ export default class Cursor { let img; try { // IE doesn't support this - img = new ImageData(new Uint8ClampedArray(cur), w, h); + img = new ImageData(new Uint8ClampedArray(rgba), w, h); } catch (ex) { img = ctx.createImageData(w, h); - img.data.set(new Uint8ClampedArray(cur)); + img.data.set(new Uint8ClampedArray(rgba)); } ctx.clearRect(0, 0, w, h); ctx.putImageData(img, 0, 0); diff --git a/docs/API.md b/docs/API.md index a81da5c..ae7fb66 100644 --- a/docs/API.md +++ b/docs/API.md @@ -53,6 +53,11 @@ protocol stream. should be sent whenever the container changes dimensions. Disabled by default. +`showDotCursor` + - Is a `boolean` indicating whether a dot cursor should be shown + instead of a zero-sized or fully-transparent cursor if the server + sets such invisible cursor. Disabled by default. + `capabilities` *Read only* - Is an `Object` indicating which optional extensions are available on the server. Some methods may only be called if the corresponding diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index cad80ef..5399b48 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -61,6 +61,9 @@ query string. Currently the following options are available: * `resize` - How to resize the remote session if it is not the same size as the browser window. Can be one of `off`, `scale` and `remote`. +* `show_dot` - If a dot cursor should be shown when the remote server provides + no local cursor, or provides a fully-transparent (invisible) cursor. + * `logging` - The console log level. Can be one of `error`, `warn`, `info` or `debug`. @@ -250,6 +250,10 @@ <input id="noVNC_setting_reconnect_delay" type="number" /> </li> <li><hr></li> + <li> + <label><input id="noVNC_setting_show_dot" type="checkbox" /> Show Dot when No Cursor</label> + </li> + <li><hr></li> <!-- Logging selection dropdown --> <li> <label>Logging: diff --git a/vnc_lite.html b/vnc_lite.html index e5ab3c2..dd1c4d8 100644 --- a/vnc_lite.html +++ b/vnc_lite.html @@ -4,59 +4,67 @@ <!-- noVNC example: lightweight example using minimal UI and features + + This is a self-contained file which doesn't import WebUtil or external CSS. + Copyright (C) 2012 Joel Martin - Copyright (C) 2017 Samuel Mannehed for Cendio AB + Copyright (C) 2018 Samuel Mannehed for Cendio AB noVNC is licensed under the MPL 2.0 (see LICENSE.txt) This file is licensed under the 2-Clause BSD license (see LICENSE.txt). Connect parameters are provided in query string: - http://example.com/?host=HOST&port=PORT&encrypt=1 - or the fragment: - http://example.com/#host=HOST&port=PORT&encrypt=1 + http://example.com/?host=HOST&port=PORT&scale=true --> <title>noVNC</title> <meta charset="utf-8"> - <!-- Always force latest IE rendering engine (even in intranet) & Chrome Frame - Remove this if you use the .htaccess --> + <!-- Always force latest IE rendering engine (even in intranet) & + Chrome Frame. Remove this if you use the .htaccess --> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> - <!-- Icons (see app/images/icons/Makefile for what the sizes are for) --> - <link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/novnc-16x16.png"> - <link rel="icon" sizes="24x24" type="image/png" href="app/images/icons/novnc-24x24.png"> - <link rel="icon" sizes="32x32" type="image/png" href="app/images/icons/novnc-32x32.png"> - <link rel="icon" sizes="48x48" type="image/png" href="app/images/icons/novnc-48x48.png"> - <link rel="icon" sizes="60x60" type="image/png" href="app/images/icons/novnc-60x60.png"> - <link rel="icon" sizes="64x64" type="image/png" href="app/images/icons/novnc-64x64.png"> - <link rel="icon" sizes="72x72" type="image/png" href="app/images/icons/novnc-72x72.png"> - <link rel="icon" sizes="76x76" type="image/png" href="app/images/icons/novnc-76x76.png"> - <link rel="icon" sizes="96x96" type="image/png" href="app/images/icons/novnc-96x96.png"> - <link rel="icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-120x120.png"> - <link rel="icon" sizes="144x144" type="image/png" href="app/images/icons/novnc-144x144.png"> - <link rel="icon" sizes="152x152" type="image/png" href="app/images/icons/novnc-152x152.png"> - <link rel="icon" sizes="192x192" type="image/png" href="app/images/icons/novnc-192x192.png"> - <!-- Firefox currently mishandles SVG, see #1419039 - <link rel="icon" sizes="any" type="image/svg+xml" href="app/images/icons/novnc-icon.svg"> - --> - <!-- Repeated last so that legacy handling will pick this --> - <link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/novnc-16x16.png"> - - <!-- Apple iOS Safari settings --> - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> - <meta name="apple-mobile-web-app-capable" content="yes" /> - <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> - <!-- Home Screen Icons (favourites and bookmarks use the normal icons) --> - <link rel="apple-touch-icon" sizes="60x60" type="image/png" href="app/images/icons/novnc-60x60.png"> - <link rel="apple-touch-icon" sizes="76x76" type="image/png" href="app/images/icons/novnc-76x76.png"> - <link rel="apple-touch-icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-120x120.png"> - <link rel="apple-touch-icon" sizes="152x152" type="image/png" href="app/images/icons/novnc-152x152.png"> - - <!-- Stylesheets --> - <link rel="stylesheet" href="app/styles/lite.css"> - - <!-- promise polyfills promises for IE11 --> + <style type="text/css"> + + body { + margin: 0; + background-color: dimgrey; + height: 100%; + display: flex; + flex-direction: column; + } + html { + height: 100%; + } + + #top_bar { + background-color: #6e84a3; + color: white; + font: bold 12px Helvetica; + padding: 6px 5px 4px 5px; + border-bottom: 1px outset; + } + #status { + text-align: center; + } + #sendCtrlAltDelButton { + position: fixed; + top: 0px; + right: 0px; + border: 1px outset; + padding: 5px 5px 4px 5px; + cursor: pointer; + } + + #screen { + flex: 1; /* fill remaining space */ + overflow: hidden; + } + + </style> + + <!-- Promise polyfill for IE11 --> <script src="vendor/promise.js"></script> + <!-- ES2015/ES6 modules polyfill --> <script type="module"> window._noVNC_has_module_support = true; @@ -64,191 +72,135 @@ <script> window.addEventListener("load", function() { if (window._noVNC_has_module_support) return; - var loader = document.createElement("script"); - loader.src = "vendor/browser-es-module-loader/dist/browser-es-module-loader.js"; + const loader = document.createElement("script"); + loader.src = "vendor/browser-es-module-loader/dist/" + + "browser-es-module-loader.js"; document.head.appendChild(loader); }); </script> <!-- actual script modules --> <script type="module" crossorigin="anonymous"> - // Load supporting scripts - import * as WebUtil from './app/webutil.js'; + // RFB holds the API to connect and communicate with a VNC server import RFB from './core/rfb.js'; - var rfb; - var desktopName; + let rfb; + let desktopName; - function updateDesktopName(e) { - desktopName = e.detail.name; + // When this function is called we have + // successfully connected to a server + function connectedToServer(e) { + status("Connected to " + desktopName); + } + + // This function is called when we are disconnected + function disconnectedFromServer(e) { + if (e.detail.clean) { + status("Disconnected"); + } else { + status("Something went wrong, connection is closed"); + } } - function credentials(e) { - var html; - - var form = document.createElement('form'); - form.innerHTML = '<label></label>'; - form.innerHTML += '<input type=password size=10 id="password_input">'; - form.onsubmit = setPassword; - - // bypass status() because it sets text content - document.getElementById('noVNC_status_bar').setAttribute("class", "noVNC_status_warn"); - document.getElementById('noVNC_status').innerHTML = ''; - document.getElementById('noVNC_status').appendChild(form); - document.getElementById('noVNC_status').querySelector('label').textContent = 'Password Required: '; + + // When this function is called, the server requires + // credentials to authenticate + function credentialsAreRequired(e) { + const password = prompt("Password Required:"); + rfb.sendCredentials({ password: password }); } - function setPassword() { - rfb.sendCredentials({ password: document.getElementById('password_input').value }); - return false; + + // When this function is called we have received + // a desktop name from the server + function updateDesktopName(e) { + desktopName = e.detail.name; } + + // Since most operating systems will catch Ctrl+Alt+Del + // before they get a chance to be intercepted by the browser, + // we provide a way to emulate this key sequence. function sendCtrlAltDel() { rfb.sendCtrlAltDel(); return false; } - function machineShutdown() { - rfb.machineShutdown(); - return false; - } - function machineReboot() { - rfb.machineReboot(); - return false; - } - function machineReset() { - rfb.machineReset(); - return false; - } - function status(text, level) { - switch (level) { - case 'normal': - case 'warn': - case 'error': - break; - default: - level = "warn"; - } - document.getElementById('noVNC_status_bar').className = "noVNC_status_" + level; - document.getElementById('noVNC_status').textContent = text; - } - function connected(e) { - document.getElementById('sendCtrlAltDelButton').disabled = false; - if (WebUtil.getConfigVar('encrypt', - (window.location.protocol === "https:"))) { - status("Connected (encrypted) to " + desktopName, "normal"); - } else { - status("Connected (unencrypted) to " + desktopName, "normal"); - } + // Show a status text in the top bar + function status(text) { + document.getElementById('status').textContent = text; } - function disconnected(e) { - document.getElementById('sendCtrlAltDelButton').disabled = true; - updatePowerButtons(); - if (e.detail.clean) { - status("Disconnected", "normal"); - } else { - status("Something went wrong, connection is closed", "error"); + // This function extracts the value of one variable from the + // query string. If the variable isn't defined in the URL + // it returns the default value instead. + function readQueryVariable(name, defaultValue) { + // A URL with a query parameter can look like this: + // https://www.example.com?myqueryparam=myvalue + // + // Note that we use location.href instead of location.search + // because Firefox < 53 has a bug w.r.t location.search + const re = new RegExp('.*[?&]' + name + '=([^&#]*)'), + match = document.location.href.match(re); + if (typeof defaultValue === 'undefined') { defaultValue = null; } + + if (match) { + // We have to decode the URL since want the cleartext value + return decodeURIComponent(match[1]); } - } - function updatePowerButtons() { - var powerbuttons; - powerbuttons = document.getElementById('noVNC_power_buttons'); - if (rfb.capabilities.power) { - powerbuttons.className= "noVNC_shown"; - } else { - powerbuttons.className = "noVNC_hidden"; - } + return defaultValue; } - document.getElementById('sendCtrlAltDelButton').onclick = sendCtrlAltDel; - document.getElementById('machineShutdownButton').onclick = machineShutdown; - document.getElementById('machineRebootButton').onclick = machineReboot; - document.getElementById('machineResetButton').onclick = machineReset; + document.getElementById('sendCtrlAltDelButton') + .onclick = sendCtrlAltDel; - WebUtil.init_logging(WebUtil.getConfigVar('logging', 'warn')); - document.title = WebUtil.getConfigVar('title', 'noVNC'); + // Read parameters specified in the URL query string // By default, use the host and port of server that served this file - var host = WebUtil.getConfigVar('host', window.location.hostname); - var port = WebUtil.getConfigVar('port', window.location.port); - - // if port == 80 (or 443) then it won't be present and should be - // set manually - if (!port) { - if (window.location.protocol.substring(0,5) == 'https') { - port = 443; - } - else if (window.location.protocol.substring(0,4) == 'http') { - port = 80; - } + const host = readQueryVariable('host', window.location.hostname); + let port = readQueryVariable('port', window.location.port); + const password = readQueryVariable('password', ''); + const path = readQueryVariable('path', 'websockify'); + + // | | | | | | + // | | | Connect | | | + // v v v v v v + + status("Connecting"); + + // Build the websocket URL used to connect + let url; + if (window.location.protocol === "https:") { + url = 'wss'; + } else { + url = 'ws'; } - - var password = WebUtil.getConfigVar('password', ''); - var path = WebUtil.getConfigVar('path', 'websockify'); - - // If a token variable is passed in, set the parameter in a cookie. - // This is used by nova-novncproxy. - var token = WebUtil.getConfigVar('token', null); - if (token) { - // if token is already present in the path we should use it - path = WebUtil.injectParamIfMissing(path, "token", token); - - WebUtil.createCookie('token', token, 1) + url += '://' + host; + if(port) { + url += ':' + port; } + url += '/' + path; - (function() { - - status("Connecting", "normal"); + // Creating a new RFB object will start a new connection + rfb = new RFB(document.getElementById('screen'), url, + { credentials: { password: password } }); - if ((!host) || (!port)) { - status('Must specify host and port in URL', 'error'); - } - - var url; + // Add listeners to important events from the RFB module + rfb.addEventListener("connect", connectedToServer); + rfb.addEventListener("disconnect", disconnectedFromServer); + rfb.addEventListener("credentialsrequired", credentialsAreRequired); + rfb.addEventListener("desktopname", updateDesktopName); - if (WebUtil.getConfigVar('encrypt', - (window.location.protocol === "https:"))) { - url = 'wss'; - } else { - url = 'ws'; - } - - url += '://' + host; - if(port) { - url += ':' + port; - } - url += '/' + path; - - rfb = new RFB(document.body, url, - { repeaterID: WebUtil.getConfigVar('repeaterID', ''), - shared: WebUtil.getConfigVar('shared', true), - credentials: { password: password } }); - rfb.viewOnly = WebUtil.getConfigVar('view_only', false); - rfb.addEventListener("connect", connected); - rfb.addEventListener("disconnect", disconnected); - rfb.addEventListener("capabilities", updatePowerButtons); - rfb.addEventListener("credentialsrequired", credentials); - rfb.addEventListener("desktopname", updateDesktopName); - rfb.scaleViewport = WebUtil.getConfigVar('scale', false); - rfb.resizeSession = WebUtil.getConfigVar('resize', false); - })(); + // Set parameters that can be changed on an active connection + rfb.viewOnly = readQueryVariable('view_only', false); + rfb.scaleViewport = readQueryVariable('scale', false); </script> </head> <body> - <div id="noVNC_status_bar"> - <div id="noVNC_left_dummy_elem"></div> - <div id="noVNC_status">Loading</div> - <div id="noVNC_buttons"> - <input type=button value="Send CtrlAltDel" - id="sendCtrlAltDelButton" class="noVNC_shown"> - <span id="noVNC_power_buttons" class="noVNC_hidden"> - <input type=button value="Shutdown" - id="machineShutdownButton"> - <input type=button value="Reboot" - id="machineRebootButton"> - <input type=button value="Reset" - id="machineResetButton"> - </span> + <div id="top_bar"> + <div id="status">Loading</div> + <div id="sendCtrlAltDelButton">Send CtrlAltDel</div> + </div> + <div id="screen"> + <!-- This is where the remote screen will appear --> </div> - </div> </body> </html> |