From 546edcd4a0c04a2ccfa96852dcd0169058c73500 Mon Sep 17 00:00:00 2001 From: Alvin Townsend Date: Fri, 31 Jan 2020 11:34:53 +0100 Subject: [PATCH 01/19] Correcting path to package.json for running at a path other than root. --- app/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui.js b/app/ui.js index 766736ae..3f874511 100644 --- a/app/ui.js +++ b/app/ui.js @@ -61,7 +61,7 @@ const UI = { // Translate the DOM l10n.translateDOM(); - WebUtil.fetchJSON('../package.json') + WebUtil.fetchJSON('./package.json') .then((packageInfo) => { Array.from(document.getElementsByClassName('noVNC_version')).forEach(el => el.innerText = packageInfo.version); }) From 3b562e8a0f0c15be8d42ce171b296594988d321e Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Tue, 28 Jan 2020 10:01:54 +0100 Subject: [PATCH 02/19] Make clipBoardPasteFrom() test more specific Don't rely on clientCutText() to test clipboardPasteFrom(). --- tests/test.rfb.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test.rfb.js b/tests/test.rfb.js index 17320e46..0143fe69 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -291,12 +291,18 @@ describe('Remote Frame Buffer Protocol Client', function () { }); describe('#clipboardPasteFrom', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'clientCutText'); + }); + + afterEach(function () { + RFB.messages.clientCutText.restore(); + }); + it('should send the given text in a paste event', function () { - const expected = {_sQ: new Uint8Array(11), _sQlen: 0, - _sQbufferSize: 11, flush: () => {}}; - RFB.messages.clientCutText(expected, 'abc'); client.clipboardPasteFrom('abc'); - expect(client._sock).to.have.sent(expected._sQ); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(client._sock, 'abc'); }); it('should flush multiple times for large clipboards', function () { From f52e979082926ab535f36f33e29693988a4bdfef Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Fri, 7 Feb 2020 13:23:21 +0100 Subject: [PATCH 03/19] Add deflator helper class for deflating data Wraps pako's deflate for easier usage. --- core/deflator.js | 79 ++++++++++++++++++++++++++++++++ tests/test.deflator.js | 80 +++++++++++++++++++++++++++++++++ vendor/pako/lib/zlib/deflate.js | 60 ++++++++++++------------- 3 files changed, 189 insertions(+), 30 deletions(-) create mode 100644 core/deflator.js create mode 100644 tests/test.deflator.js diff --git a/core/deflator.js b/core/deflator.js new file mode 100644 index 00000000..ad3d0fb7 --- /dev/null +++ b/core/deflator.js @@ -0,0 +1,79 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2020 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + */ + +import { deflateInit, deflate } from "../vendor/pako/lib/zlib/deflate.js"; +import { Z_FULL_FLUSH } from "../vendor/pako/lib/zlib/deflate.js"; +import ZStream from "../vendor/pako/lib/zlib/zstream.js"; + +export default class Deflator { + constructor() { + this.strm = new ZStream(); + this.chunkSize = 1024 * 10 * 10; + this.outputBuffer = new Uint8Array(this.chunkSize); + this.windowBits = 5; + + deflateInit(this.strm, this.windowBits); + } + + deflate(inData) { + this.strm.input = inData; + this.strm.avail_in = this.strm.input.length; + this.strm.next_in = 0; + this.strm.output = this.outputBuffer; + this.strm.avail_out = this.chunkSize; + this.strm.next_out = 0; + + let lastRet = deflate(this.strm, Z_FULL_FLUSH); + let outData = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out); + + if (lastRet < 0) { + throw new Error("zlib deflate failed"); + } + + if (this.strm.avail_in > 0) { + // Read chunks until done + + let chunks = [outData]; + let totalLen = outData.length; + do { + this.strm.output = new Uint8Array(this.chunkSize); + this.strm.next_out = 0; + this.strm.avail_out = this.chunkSize; + + lastRet = deflate(this.strm, Z_FULL_FLUSH); + + if (lastRet < 0) { + throw new Error("zlib deflate failed"); + } + + let chunk = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out); + totalLen += chunk.length; + chunks.push(chunk); + } while (this.strm.avail_in > 0); + + // Combine chunks into a single data + + let newData = new Uint8Array(totalLen); + let offset = 0; + + for (let i = 0; i < chunks.length; i++) { + newData.set(chunks[i], offset); + offset += chunks[i].length; + } + + outData = newData; + } + + this.strm.input = null; + this.strm.avail_in = 0; + this.strm.next_in = 0; + + return outData; + } + +} diff --git a/tests/test.deflator.js b/tests/test.deflator.js new file mode 100644 index 00000000..2f2fab3a --- /dev/null +++ b/tests/test.deflator.js @@ -0,0 +1,80 @@ +/* eslint-disable no-console */ +const expect = chai.expect; + +import { inflateInit, inflate } from "../vendor/pako/lib/zlib/inflate.js"; +import ZStream from "../vendor/pako/lib/zlib/zstream.js"; +import Deflator from "../core/deflator.js"; + +function _inflator(compText, expected) { + let strm = new ZStream(); + let chunkSize = 1024 * 10 * 10; + strm.output = new Uint8Array(chunkSize); + + inflateInit(strm, 5); + + if (expected > chunkSize) { + chunkSize = expected; + strm.output = new Uint8Array(chunkSize); + } + + strm.input = compText; + strm.avail_in = strm.input.length; + strm.next_in = 0; + + strm.next_out = 0; + strm.avail_out = expected.length; + + let ret = inflate(strm, 0); + + // Check that return code is not an error + expect(ret).to.be.greaterThan(-1); + + return new Uint8Array(strm.output.buffer, 0, strm.next_out); +} + +describe('Deflate data', function () { + + it('should be able to deflate messages', function () { + let deflator = new Deflator(); + + let text = "123asdf"; + let preText = new Uint8Array(text.length); + for (let i = 0; i < preText.length; i++) { + preText[i] = text.charCodeAt(i); + } + + let compText = deflator.deflate(preText); + + let inflatedText = _inflator(compText, text.length); + expect(inflatedText).to.array.equal(preText); + + }); + + it('should be able to deflate large messages', function () { + let deflator = new Deflator(); + + /* Generate a big string with random characters. Used because + repetition of letters might be deflated more effectively than + random ones. */ + let text = ""; + let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 300000; i++) { + text += characters.charAt(Math.floor(Math.random() * characters.length)); + } + + let preText = new Uint8Array(text.length); + for (let i = 0; i < preText.length; i++) { + preText[i] = text.charCodeAt(i); + } + + let compText = deflator.deflate(preText); + + //Check that the compressed size is expected size + expect(compText.length).to.be.greaterThan((1024 * 10 * 10) * 2); + + let inflatedText = _inflator(compText, text.length); + + expect(inflatedText).to.array.equal(preText); + + }); +}); diff --git a/vendor/pako/lib/zlib/deflate.js b/vendor/pako/lib/zlib/deflate.js index c51915e2..c3a5ba49 100644 --- a/vendor/pako/lib/zlib/deflate.js +++ b/vendor/pako/lib/zlib/deflate.js @@ -9,51 +9,51 @@ import msg from "./messages.js"; /* Allowed flush values; see deflate() and inflate() below for details */ -var Z_NO_FLUSH = 0; -var Z_PARTIAL_FLUSH = 1; -//var Z_SYNC_FLUSH = 2; -var Z_FULL_FLUSH = 3; -var Z_FINISH = 4; -var Z_BLOCK = 5; -//var Z_TREES = 6; +export const Z_NO_FLUSH = 0; +export const Z_PARTIAL_FLUSH = 1; +//export const Z_SYNC_FLUSH = 2; +export const Z_FULL_FLUSH = 3; +export const Z_FINISH = 4; +export const Z_BLOCK = 5; +//export const Z_TREES = 6; /* Return codes for the compression/decompression functions. Negative values * are errors, positive values are used for special but normal events. */ -var Z_OK = 0; -var Z_STREAM_END = 1; -//var Z_NEED_DICT = 2; -//var Z_ERRNO = -1; -var Z_STREAM_ERROR = -2; -var Z_DATA_ERROR = -3; -//var Z_MEM_ERROR = -4; -var Z_BUF_ERROR = -5; -//var Z_VERSION_ERROR = -6; +export const Z_OK = 0; +export const Z_STREAM_END = 1; +//export const Z_NEED_DICT = 2; +//export const Z_ERRNO = -1; +export const Z_STREAM_ERROR = -2; +export const Z_DATA_ERROR = -3; +//export const Z_MEM_ERROR = -4; +export const Z_BUF_ERROR = -5; +//export const Z_VERSION_ERROR = -6; /* compression levels */ -//var Z_NO_COMPRESSION = 0; -//var Z_BEST_SPEED = 1; -//var Z_BEST_COMPRESSION = 9; -var Z_DEFAULT_COMPRESSION = -1; +//export const Z_NO_COMPRESSION = 0; +//export const Z_BEST_SPEED = 1; +//export const Z_BEST_COMPRESSION = 9; +export const Z_DEFAULT_COMPRESSION = -1; -var Z_FILTERED = 1; -var Z_HUFFMAN_ONLY = 2; -var Z_RLE = 3; -var Z_FIXED = 4; -var Z_DEFAULT_STRATEGY = 0; +export const Z_FILTERED = 1; +export const Z_HUFFMAN_ONLY = 2; +export const Z_RLE = 3; +export const Z_FIXED = 4; +export const Z_DEFAULT_STRATEGY = 0; /* Possible values of the data_type field (though see inflate()) */ -//var Z_BINARY = 0; -//var Z_TEXT = 1; -//var Z_ASCII = 1; // = Z_TEXT -var Z_UNKNOWN = 2; +//export const Z_BINARY = 0; +//export const Z_TEXT = 1; +//export const Z_ASCII = 1; // = Z_TEXT +export const Z_UNKNOWN = 2; /* The deflate compression method */ -var Z_DEFLATED = 8; +export const Z_DEFLATED = 8; /*============================================================================*/ From 9575ded8da83b6d8774b36316c388279fa0512cc Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Tue, 28 Jan 2020 17:00:04 +0100 Subject: [PATCH 04/19] Add util for unsigned and signed int. conversion Will be used in later commit in extended clipboard handling. --- core/util/int.js | 15 +++++++++++++++ tests/test.int.js | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 core/util/int.js create mode 100644 tests/test.int.js diff --git a/core/util/int.js b/core/util/int.js new file mode 100644 index 00000000..001f40f2 --- /dev/null +++ b/core/util/int.js @@ -0,0 +1,15 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2020 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + */ + +export function toUnsigned32bit(toConvert) { + return toConvert >>> 0; +} + +export function toSigned32bit(toConvert) { + return toConvert | 0; +} diff --git a/tests/test.int.js b/tests/test.int.js new file mode 100644 index 00000000..954fd279 --- /dev/null +++ b/tests/test.int.js @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ +const expect = chai.expect; + +import { toUnsigned32bit, toSigned32bit } from '../core/util/int.js'; + +describe('Integer casting', function () { + it('should cast unsigned to signed', function () { + let expected = 4294967286; + expect(toUnsigned32bit(-10)).to.equal(expected); + }); + + it('should cast signed to unsigned', function () { + let expected = -10; + expect(toSigned32bit(4294967286)).to.equal(expected); + }); +}); From 183cab0ecaa57865d777779dbeb3826cfce8d296 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 3 Feb 2020 09:53:30 +0100 Subject: [PATCH 05/19] Remove unused inflate argument The value true was an invalid flush argument so it was in practice unused. --- core/decoders/tight.js | 4 ++-- core/inflator.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/decoders/tight.js b/core/decoders/tight.js index 7695d447..5a0a315f 100644 --- a/core/decoders/tight.js +++ b/core/decoders/tight.js @@ -160,7 +160,7 @@ export default class TightDecoder { return false; } - data = this._zlibs[streamId].inflate(data, true, uncompressedSize); + data = this._zlibs[streamId].inflate(data, uncompressedSize); if (data.length != uncompressedSize) { throw new Error("Incomplete zlib block"); } @@ -208,7 +208,7 @@ export default class TightDecoder { return false; } - data = this._zlibs[streamId].inflate(data, true, uncompressedSize); + data = this._zlibs[streamId].inflate(data, uncompressedSize); if (data.length != uncompressedSize) { throw new Error("Incomplete zlib block"); } diff --git a/core/inflator.js b/core/inflator.js index 0eab8fe4..fe9f8c7d 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -11,7 +11,7 @@ export default class Inflate { inflateInit(this.strm, this.windowBits); } - inflate(data, flush, expected) { + inflate(data, expected) { this.strm.input = data; this.strm.avail_in = this.strm.input.length; this.strm.next_in = 0; @@ -27,7 +27,7 @@ export default class Inflate { this.strm.avail_out = this.chunkSize; - inflate(this.strm, flush); + inflate(this.strm, 0); // Flush argument not used. return new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out); } From fe5aa6408aed83ec07e640cc9c0249688a7b710b Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Tue, 11 Feb 2020 14:20:56 +0100 Subject: [PATCH 06/19] Add missing copyright header for Inflator.js --- core/inflator.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/inflator.js b/core/inflator.js index fe9f8c7d..b7af040f 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -1,3 +1,11 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2020 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + */ + import { inflateInit, inflate, inflateReset } from "../vendor/pako/lib/zlib/inflate.js"; import ZStream from "../vendor/pako/lib/zlib/zstream.js"; From f6669ff7b2e489f0a55d2808ede674e64f777e7d Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 3 Feb 2020 09:57:56 +0100 Subject: [PATCH 07/19] Move error handling to Inflate class Every call wants this check so this should be done inside the class. --- core/decoders/tight.js | 6 ------ core/inflator.js | 4 ++++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/core/decoders/tight.js b/core/decoders/tight.js index 5a0a315f..c226b33b 100644 --- a/core/decoders/tight.js +++ b/core/decoders/tight.js @@ -161,9 +161,6 @@ export default class TightDecoder { } data = this._zlibs[streamId].inflate(data, uncompressedSize); - if (data.length != uncompressedSize) { - throw new Error("Incomplete zlib block"); - } } display.blitRgbImage(x, y, width, height, data, 0, false); @@ -209,9 +206,6 @@ export default class TightDecoder { } data = this._zlibs[streamId].inflate(data, uncompressedSize); - if (data.length != uncompressedSize) { - throw new Error("Incomplete zlib block"); - } } // Convert indexed (palette based) image data to RGB diff --git a/core/inflator.js b/core/inflator.js index b7af040f..726600f9 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -37,6 +37,10 @@ export default class Inflate { inflate(this.strm, 0); // Flush argument not used. + if (this.strm.next_out != expected) { + throw new Error("Incomplete zlib block"); + } + return new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out); } From 3cf11004b476b66296714e0d7b85437d40604cc3 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 3 Feb 2020 10:04:20 +0100 Subject: [PATCH 08/19] Handle errors from zlib/pako --- core/inflator.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/inflator.js b/core/inflator.js index 726600f9..39db447a 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -35,7 +35,10 @@ export default class Inflate { this.strm.avail_out = this.chunkSize; - inflate(this.strm, 0); // Flush argument not used. + let ret = inflate(this.strm, 0); // Flush argument not used. + if (ret < 0) { + throw new Error("zlib inflate failed"); + } if (this.strm.next_out != expected) { throw new Error("Incomplete zlib block"); From 2cee106eee9d01216a627406757beede85341a51 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 3 Feb 2020 10:19:00 +0100 Subject: [PATCH 09/19] Split api of inflate Added ability to read data chunk wise. --- core/decoders/tight.js | 8 ++++++-- core/inflator.js | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/core/decoders/tight.js b/core/decoders/tight.js index c226b33b..b207419e 100644 --- a/core/decoders/tight.js +++ b/core/decoders/tight.js @@ -160,7 +160,9 @@ export default class TightDecoder { return false; } - data = this._zlibs[streamId].inflate(data, uncompressedSize); + this._zlibs[streamId].setInput(data); + data = this._zlibs[streamId].inflate(uncompressedSize); + this._zlibs[streamId].setInput(null); } display.blitRgbImage(x, y, width, height, data, 0, false); @@ -205,7 +207,9 @@ export default class TightDecoder { return false; } - data = this._zlibs[streamId].inflate(data, uncompressedSize); + this._zlibs[streamId].setInput(data); + data = this._zlibs[streamId].inflate(uncompressedSize); + this._zlibs[streamId].setInput(null); } // Convert indexed (palette based) image data to RGB diff --git a/core/inflator.js b/core/inflator.js index 39db447a..c85501ff 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -19,12 +19,20 @@ export default class Inflate { inflateInit(this.strm, this.windowBits); } - inflate(data, expected) { - this.strm.input = data; - this.strm.avail_in = this.strm.input.length; - this.strm.next_in = 0; - this.strm.next_out = 0; + setInput(data) { + if (!data) { + //FIXME: flush remaining data. + this.strm.input = null; + this.strm.avail_in = 0; + this.strm.next_in = 0; + } else { + this.strm.input = data; + this.strm.avail_in = this.strm.input.length; + this.strm.next_in = 0; + } + } + inflate(expected) { // resize our output buffer if it's too small // (we could just use multiple chunks, but that would cause an extra // allocation each time to flatten the chunks) @@ -33,6 +41,7 @@ export default class Inflate { this.strm.output = new Uint8Array(this.chunkSize); } + this.strm.next_out = 0; this.strm.avail_out = this.chunkSize; let ret = inflate(this.strm, 0); // Flush argument not used. From 13be552d60f399d8618af5dc22eb4e0838cd9d8e Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Tue, 4 Feb 2020 09:55:49 +0100 Subject: [PATCH 10/19] Fix bug where inflate would read too much data --- core/inflator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/inflator.js b/core/inflator.js index c85501ff..e61a5bd4 100644 --- a/core/inflator.js +++ b/core/inflator.js @@ -42,7 +42,7 @@ export default class Inflate { } this.strm.next_out = 0; - this.strm.avail_out = this.chunkSize; + this.strm.avail_out = expected; let ret = inflate(this.strm, 0); // Flush argument not used. if (ret < 0) { From 9a31083a8ae4f1a3cfd4977cb1b05151a83bcf26 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 17 Feb 2020 10:27:51 +0100 Subject: [PATCH 11/19] Export constants in inflate.js for easier usage --- vendor/pako/lib/zlib/inflate.js | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/vendor/pako/lib/zlib/inflate.js b/vendor/pako/lib/zlib/inflate.js index b79b3963..1d2063bc 100644 --- a/vendor/pako/lib/zlib/inflate.js +++ b/vendor/pako/lib/zlib/inflate.js @@ -13,30 +13,30 @@ var DISTS = 2; /* Allowed flush values; see deflate() and inflate() below for details */ -//var Z_NO_FLUSH = 0; -//var Z_PARTIAL_FLUSH = 1; -//var Z_SYNC_FLUSH = 2; -//var Z_FULL_FLUSH = 3; -var Z_FINISH = 4; -var Z_BLOCK = 5; -var Z_TREES = 6; +//export const Z_NO_FLUSH = 0; +//export const Z_PARTIAL_FLUSH = 1; +//export const Z_SYNC_FLUSH = 2; +//export const Z_FULL_FLUSH = 3; +export const Z_FINISH = 4; +export const Z_BLOCK = 5; +export const Z_TREES = 6; /* Return codes for the compression/decompression functions. Negative values * are errors, positive values are used for special but normal events. */ -var Z_OK = 0; -var Z_STREAM_END = 1; -var Z_NEED_DICT = 2; -//var Z_ERRNO = -1; -var Z_STREAM_ERROR = -2; -var Z_DATA_ERROR = -3; -var Z_MEM_ERROR = -4; -var Z_BUF_ERROR = -5; -//var Z_VERSION_ERROR = -6; +export const Z_OK = 0; +export const Z_STREAM_END = 1; +export const Z_NEED_DICT = 2; +//export const Z_ERRNO = -1; +export const Z_STREAM_ERROR = -2; +export const Z_DATA_ERROR = -3; +export const Z_MEM_ERROR = -4; +export const Z_BUF_ERROR = -5; +//export const Z_VERSION_ERROR = -6; /* The deflate compression method */ -var Z_DEFLATED = 8; +export const Z_DEFLATED = 8; /* STATES ====================================================================*/ From f73fdc3ed3db6a47cc95a17200b7a0d1fdc91ab8 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 27 Jan 2020 13:49:07 +0100 Subject: [PATCH 12/19] Add extended clipboard Pseudo-Encoding Add extended clipboard pseudo-encoding to allow the use of unicode characters in the clipboard. --- core/encodings.js | 3 +- core/rfb.js | 320 ++++++++++++++++++++++++++++++-- tests/test.rfb.js | 457 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 741 insertions(+), 39 deletions(-) diff --git a/core/encodings.js b/core/encodings.js index c2488403..51c09929 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -27,7 +27,8 @@ export const encodings = { pseudoEncodingContinuousUpdates: -313, pseudoEncodingCompressLevel9: -247, pseudoEncodingCompressLevel0: -256, - pseudoEncodingVMwareCursor: 0x574d5664 + pseudoEncodingVMwareCursor: 0x574d5664, + pseudoEncodingExtendedClipboard: 0xc0a1e5ce }; export function encodingName(num) { diff --git a/core/rfb.js b/core/rfb.js index e3e3a0f7..f0d2a797 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -1,17 +1,20 @@ /* * noVNC: HTML5 VNC client - * Copyright (C) 2019 The noVNC Authors + * Copyright (C) 2020 The noVNC Authors * Licensed under MPL 2.0 (see LICENSE.txt) * * See README.md for usage and integration instructions. * */ +import { toUnsigned32bit, toSigned32bit } from './util/int.js'; import * as Log from './util/logging.js'; -import { decodeUTF8 } from './util/strings.js'; +import { encodeUTF8, decodeUTF8 } from './util/strings.js'; import { dragThreshold } from './util/browser.js'; import EventTargetMixin from './util/eventtarget.js'; import Display from "./display.js"; +import Inflator from "./inflator.js"; +import Deflator from "./deflator.js"; import Keyboard from "./input/keyboard.js"; import Mouse from "./input/mouse.js"; import Cursor from "./util/cursor.js"; @@ -33,6 +36,23 @@ import TightPNGDecoder from "./decoders/tightpng.js"; const DISCONNECT_TIMEOUT = 3; const DEFAULT_BACKGROUND = 'rgb(40, 40, 40)'; +// Extended clipboard pseudo-encoding formats +const extendedClipboardFormatText = 1; +/*eslint-disable no-unused-vars */ +const extendedClipboardFormatRtf = 1 << 1; +const extendedClipboardFormatHtml = 1 << 2; +const extendedClipboardFormatDib = 1 << 3; +const extendedClipboardFormatFiles = 1 << 4; +/*eslint-enable */ + +// Extended clipboard pseudo-encoding actions +const extendedClipboardActionCaps = 1 << 24; +const extendedClipboardActionRequest = 1 << 25; +const extendedClipboardActionPeek = 1 << 26; +const extendedClipboardActionNotify = 1 << 27; +const extendedClipboardActionProvide = 1 << 28; + + export default class RFB extends EventTargetMixin { constructor(target, url, options) { if (!target) { @@ -84,6 +104,10 @@ export default class RFB extends EventTargetMixin { this._qemuExtKeyEventSupported = false; + this._clipboardText = null; + this._clipboardServerCapabilitiesActions = {}; + this._clipboardServerCapabilitiesFormats = {}; + // Internal objects this._sock = null; // Websock object this._display = null; // Display object @@ -390,7 +414,21 @@ export default class RFB extends EventTargetMixin { clipboardPasteFrom(text) { if (this._rfb_connection_state !== 'connected' || this._viewOnly) { return; } - RFB.messages.clientCutText(this._sock, text); + + if (this._clipboardServerCapabilitiesFormats[extendedClipboardFormatText] && + this._clipboardServerCapabilitiesActions[extendedClipboardActionNotify]) { + + this._clipboardText = text; + RFB.messages.extendedClipboardNotify(this._sock, [extendedClipboardFormatText]); + } else { + let data = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) { + // FIXME: text can have values outside of Latin1/Uint8 + data[i] = text.charCodeAt(i); + } + + RFB.messages.clientCutText(this._sock, data); + } } // ===== PRIVATE METHODS ===== @@ -1267,6 +1305,7 @@ export default class RFB extends EventTargetMixin { encs.push(encodings.pseudoEncodingFence); encs.push(encodings.pseudoEncodingContinuousUpdates); encs.push(encodings.pseudoEncodingDesktopName); + encs.push(encodings.pseudoEncodingExtendedClipboard); if (this._fb_depth == 24) { encs.push(encodings.pseudoEncodingVMwareCursor); @@ -1325,18 +1364,163 @@ export default class RFB extends EventTargetMixin { Log.Debug("ServerCutText"); if (this._sock.rQwait("ServerCutText header", 7, 1)) { return false; } + this._sock.rQskipBytes(3); // Padding - const length = this._sock.rQshift32(); - if (this._sock.rQwait("ServerCutText", length, 8)) { return false; } - const text = this._sock.rQshiftStr(length); + let length = this._sock.rQshift32(); + length = toSigned32bit(length); - if (this._viewOnly) { return true; } + if (this._sock.rQwait("ServerCutText content", Math.abs(length), 8)) { return false; } - this.dispatchEvent(new CustomEvent( - "clipboard", - { detail: { text: text } })); + if (length >= 0) { + //Standard msg + const text = this._sock.rQshiftStr(length); + if (this._viewOnly) { + return true; + } + this.dispatchEvent(new CustomEvent( + "clipboard", + { detail: { text: text } })); + + } else { + //Extended msg. + length = Math.abs(length); + const flags = this._sock.rQshift32(); + let formats = flags & 0x0000FFFF; + let actions = flags & 0xFF000000; + + let isCaps = (!!(actions & extendedClipboardActionCaps)); + if (isCaps) { + this._clipboardServerCapabilitiesFormats = {}; + this._clipboardServerCapabilitiesActions = {}; + + // Update our server capabilities for Formats + for (let i = 0; i <= 15; i++) { + let index = 1 << i; + + // Check if format flag is set. + if ((formats & index)) { + this._clipboardServerCapabilitiesFormats[index] = true; + // We don't send unsolicited clipboard, so we + // ignore the size + this._sock.rQshift32(); + } + } + + // Update our server capabilities for Actions + for (let i = 24; i <= 31; i++) { + let index = 1 << i; + this._clipboardServerCapabilitiesActions[index] = !!(actions & index); + } + + /* Caps handling done, send caps with the clients + capabilities set as a response */ + let clientActions = [ + extendedClipboardActionCaps, + extendedClipboardActionRequest, + extendedClipboardActionPeek, + extendedClipboardActionNotify, + extendedClipboardActionProvide + ]; + RFB.messages.extendedClipboardCaps(this._sock, clientActions, {extendedClipboardFormatText: 0}); + + } else if (actions === extendedClipboardActionRequest) { + if (this._viewOnly) { + return true; + } + + // Check if server has told us it can handle Provide and there is clipboard data to send. + if (this._clipboardText != null && + this._clipboardServerCapabilitiesActions[extendedClipboardActionProvide]) { + + if (formats & extendedClipboardFormatText) { + RFB.messages.extendedClipboardProvide(this._sock, [extendedClipboardFormatText], [this._clipboardText]); + } + } + + } else if (actions === extendedClipboardActionPeek) { + if (this._viewOnly) { + return true; + } + + if (this._clipboardServerCapabilitiesActions[extendedClipboardActionNotify]) { + + if (this._clipboardText != null) { + RFB.messages.extendedClipboardNotify(this._sock, [extendedClipboardFormatText]); + } else { + RFB.messages.extendedClipboardNotify(this._sock, []); + } + } + + } else if (actions === extendedClipboardActionNotify) { + if (this._viewOnly) { + return true; + } + + if (this._clipboardServerCapabilitiesActions[extendedClipboardActionRequest]) { + + if (formats & extendedClipboardFormatText) { + RFB.messages.extendedClipboardRequest(this._sock, [extendedClipboardFormatText]); + } + } + + } else if (actions === extendedClipboardActionProvide) { + if (this._viewOnly) { + return true; + } + + if (!(formats & extendedClipboardFormatText)) { + return true; + } + // Ignore what we had in our clipboard client side. + this._clipboardText = null; + + // FIXME: Should probably verify that this data was actually requested + let zlibStream = this._sock.rQshiftBytes(length - 4); + let streamInflator = new Inflator(); + let textData = null; + + streamInflator.setInput(zlibStream); + for (let i = 0; i <= 15; i++) { + let format = 1 << i; + + if (formats & format) { + + let size = 0x00; + let sizeArray = streamInflator.inflate(4); + + size |= (sizeArray[0] << 24); + size |= (sizeArray[1] << 16); + size |= (sizeArray[2] << 8); + size |= (sizeArray[3]); + let chunk = streamInflator.inflate(size); + + if (format === extendedClipboardFormatText) { + textData = chunk; + } + } + } + streamInflator.setInput(null); + + if (textData !== null) { + textData = String.fromCharCode.apply(null, textData); + + textData = decodeUTF8(textData); + if ((textData.length > 0) && "\0" === textData.charAt(textData.length - 1)) { + textData = textData.slice(0, -1); + } + + textData = textData.replace("\r\n", "\n"); + + this.dispatchEvent(new CustomEvent( + "clipboard", + { detail: { text: textData } })); + } + } else { + return this._fail("Unexpected action in extended clipboard message: " + actions); + } + } return true; } @@ -1966,8 +2150,102 @@ RFB.messages = { sock.flush(); }, - // TODO(directxman12): make this unicode compatible? - clientCutText(sock, text) { + // Used to build Notify and Request data. + _buildExtendedClipboardFlags(actions, formats) { + let data = new Uint8Array(4); + let formatFlag = 0x00000000; + let actionFlag = 0x00000000; + + for (let i = 0; i < actions.length; i++) { + actionFlag |= actions[i]; + } + + for (let i = 0; i < formats.length; i++) { + formatFlag |= formats[i]; + } + + data[0] = actionFlag >> 24; // Actions + data[1] = 0x00; // Reserved + data[2] = 0x00; // Reserved + data[3] = formatFlag; // Formats + + return data; + }, + + extendedClipboardProvide(sock, formats, inData) { + // Deflate incomming data and their sizes + let deflator = new Deflator(); + let dataToDeflate = []; + + for (let i = 0; i < formats.length; i++) { + // We only support the format Text at this time + if (formats[i] != extendedClipboardFormatText) { + throw new Error("Unsupported extended clipboard format for Provide message."); + } + + // Change lone \r or \n into \r\n as defined in rfbproto + inData[i] = inData[i].replace(/\r\n|\r|\n/gm, "\r\n"); + + // Check if it already has \0 + let text = encodeUTF8(inData[i] + "\0"); + + dataToDeflate.push( (text.length >> 24) & 0xFF, + (text.length >> 16) & 0xFF, + (text.length >> 8) & 0xFF, + (text.length & 0xFF)); + + for (let j = 0; j < text.length; j++) { + dataToDeflate.push(text.charCodeAt(j)); + } + } + + let deflatedData = deflator.deflate(new Uint8Array(dataToDeflate)); + + // Build data to send + let data = new Uint8Array(4 + deflatedData.length); + data.set(RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionProvide], + formats)); + data.set(deflatedData, 4); + + RFB.messages.clientCutText(sock, data, true); + }, + + extendedClipboardNotify(sock, formats) { + let flags = RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionNotify], + formats); + RFB.messages.clientCutText(sock, flags, true); + }, + + extendedClipboardRequest(sock, formats) { + let flags = RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionRequest], + formats); + RFB.messages.clientCutText(sock, flags, true); + }, + + extendedClipboardCaps(sock, actions, formats) { + let formatKeys = Object.keys(formats); + let data = new Uint8Array(4 + (4 * formatKeys.length)); + + formatKeys.map(x => parseInt(x)); + formatKeys.sort((a, b) => a - b); + + data.set(RFB.messages._buildExtendedClipboardFlags(actions, [])); + + let loopOffset = 4; + for (let i = 0; i < formatKeys.length; i++) { + data[loopOffset] = formats[formatKeys[i]] >> 24; + data[loopOffset + 1] = formats[formatKeys[i]] >> 16; + data[loopOffset + 2] = formats[formatKeys[i]] >> 8; + data[loopOffset + 3] = formats[formatKeys[i]] >> 0; + + loopOffset += 4; + data[3] |= (1 << formatKeys[i]); // Update our format flags + } + + RFB.messages.clientCutText(sock, data, true); + }, + + clientCutText(sock, data, extended = false) { const buff = sock._sQ; const offset = sock._sQlen; @@ -1977,7 +2255,12 @@ RFB.messages = { buff[offset + 2] = 0; // padding buff[offset + 3] = 0; // padding - let length = text.length; + let length; + if (extended) { + length = toUnsigned32bit(-data.length); + } else { + length = data.length; + } buff[offset + 4] = length >> 24; buff[offset + 5] = length >> 16; @@ -1986,24 +2269,25 @@ RFB.messages = { sock._sQlen += 8; - // We have to keep track of from where in the text we begin creating the + // We have to keep track of from where in the data we begin creating the // buffer for the flush in the next iteration. - let textOffset = 0; + let dataOffset = 0; - let remaining = length; + let remaining = data.length; while (remaining > 0) { let flushSize = Math.min(remaining, (sock._sQbufferSize - sock._sQlen)); for (let i = 0; i < flushSize; i++) { - buff[sock._sQlen + i] = text.charCodeAt(textOffset + i); + buff[sock._sQlen + i] = data[dataOffset + i]; } sock._sQlen += flushSize; sock.flush(); remaining -= flushSize; - textOffset += flushSize; + dataOffset += flushSize; } + }, setDesktopSize(sock, width, height, id, flags) { diff --git a/tests/test.rfb.js b/tests/test.rfb.js index 0143fe69..42f4fbc7 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -2,7 +2,11 @@ const expect = chai.expect; import RFB from '../core/rfb.js'; import Websock from '../core/websock.js'; +import ZStream from "../vendor/pako/lib/zlib/zstream.js"; +import { deflateInit, deflate } from "../vendor/pako/lib/zlib/deflate.js"; import { encodings } from '../core/encodings.js'; +import { toUnsigned32bit } from '../core/util/int.js'; +import { encodeUTF8 } from '../core/util/strings.js'; import FakeWebSocket from './fake.websocket.js'; @@ -48,6 +52,35 @@ function pushString(arr, string) { } } +function deflateWithSize(data) { + // Adds the size of the string in front before deflating + + let unCompData = []; + unCompData.push((data.length >> 24) & 0xFF, + (data.length >> 16) & 0xFF, + (data.length >> 8) & 0xFF, + (data.length & 0xFF)); + + for (let i = 0; i < data.length; i++) { + unCompData.push(data.charCodeAt(i)); + } + + let strm = new ZStream(); + let chunkSize = 1024 * 10 * 10; + strm.output = new Uint8Array(chunkSize); + deflateInit(strm, 5); + + strm.input = unCompData; + strm.avail_in = strm.input.length; + strm.next_in = 0; + strm.next_out = 0; + strm.avail_out = chunkSize; + + deflate(strm, 3); + + return new Uint8Array(strm.output.buffer, 0, strm.next_out); +} + describe('Remote Frame Buffer Protocol Client', function () { let clock; let raf; @@ -291,18 +324,39 @@ describe('Remote Frame Buffer Protocol Client', function () { }); describe('#clipboardPasteFrom', function () { - beforeEach(function () { - sinon.spy(RFB.messages, 'clientCutText'); - }); + describe('Clipboard update handling', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'clientCutText'); + sinon.spy(RFB.messages, 'extendedClipboardNotify'); + }); - afterEach(function () { - RFB.messages.clientCutText.restore(); - }); + afterEach(function () { + RFB.messages.clientCutText.restore(); + RFB.messages.extendedClipboardNotify.restore(); + }); - it('should send the given text in a paste event', function () { - client.clipboardPasteFrom('abc'); - expect(RFB.messages.clientCutText).to.have.been.calledOnce; - expect(RFB.messages.clientCutText).to.have.been.calledWith(client._sock, 'abc'); + it('should send the given text in an clipboard update', function () { + client.clipboardPasteFrom('abc'); + + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(client._sock, + new Uint8Array([97, 98, 99])); + }); + + it('should send an notify if extended clipboard is supported by server', function () { + // Send our capabilities + let data = [3, 0, 0, 0]; + const flags = [0x1F, 0x00, 0x00, 0x01]; + let fileSizes = [0x00, 0x00, 0x00, 0x1E]; + + push32(data, toUnsigned32bit(-8)); + data = data.concat(flags); + data = data.concat(fileSizes); + client._sock._websocket._receive_data(new Uint8Array(data)); + + client.clipboardPasteFrom('extended test'); + expect(RFB.messages.extendedClipboardNotify).to.have.been.calledOnce; + }); }); it('should flush multiple times for large clipboards', function () { @@ -2342,17 +2396,217 @@ describe('Remote Frame Buffer Protocol Client', function () { }); }); - it('should fire the clipboard callback with the retrieved text on ServerCutText', function () { - const expected_str = 'cheese!'; - const data = [3, 0, 0, 0]; - push32(data, expected_str.length); - for (let i = 0; i < expected_str.length; i++) { data.push(expected_str.charCodeAt(i)); } - const spy = sinon.spy(); - client.addEventListener("clipboard", spy); + describe('Normal Clipboard Handling Receive', function () { + it('should fire the clipboard callback with the retrieved text on ServerCutText', function () { + const expected_str = 'cheese!'; + const data = [3, 0, 0, 0]; + push32(data, expected_str.length); + for (let i = 0; i < expected_str.length; i++) { data.push(expected_str.charCodeAt(i)); } + const spy = sinon.spy(); + client.addEventListener("clipboard", spy); + + client._sock._websocket._receive_data(new Uint8Array(data)); + expect(spy).to.have.been.calledOnce; + expect(spy.args[0][0].detail.text).to.equal(expected_str); + }); + }); + + describe('Extended clipboard Handling', function () { + + describe('Extended clipboard initialization', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'extendedClipboardCaps'); + }); + + afterEach(function () { + RFB.messages.extendedClipboardCaps.restore(); + }); + + it('should update capabilities when receiving a Caps message', function () { + let data = [3, 0, 0, 0]; + const flags = [0x1F, 0x00, 0x00, 0x03]; + let fileSizes = [0x00, 0x00, 0x00, 0x1E, + 0x00, 0x00, 0x00, 0x3C]; + + push32(data, toUnsigned32bit(-12)); + data = data.concat(flags); + data = data.concat(fileSizes); + client._sock._websocket._receive_data(new Uint8Array(data)); + + // Check that we give an response caps when we receive one + expect(RFB.messages.extendedClipboardCaps).to.have.been.calledOnce; + + // FIXME: Can we avoid checking internal variables? + expect(client._clipboardServerCapabilitiesFormats[0]).to.not.equal(true); + expect(client._clipboardServerCapabilitiesFormats[1]).to.equal(true); + expect(client._clipboardServerCapabilitiesFormats[2]).to.equal(true); + expect(client._clipboardServerCapabilitiesActions[(1 << 24)]).to.equal(true); + }); + + + }); + + describe('Extended Clipboard Handling Receive', function () { + + beforeEach(function () { + // Send our capabilities + let data = [3, 0, 0, 0]; + const flags = [0x1F, 0x00, 0x00, 0x01]; + let fileSizes = [0x00, 0x00, 0x00, 0x1E]; + + push32(data, toUnsigned32bit(-8)); + data = data.concat(flags); + data = data.concat(fileSizes); + client._sock._websocket._receive_data(new Uint8Array(data)); + }); + + describe('Handle Provide', function () { + it('should update clipboard with correct Unicode data from a Provide message', function () { + let expectedData = "Aå漢字!"; + let data = [3, 0, 0, 0]; + const flags = [0x10, 0x00, 0x00, 0x01]; + + /* The size 10 (utf8 encoded string size) and the + string "Aå漢字!" utf8 encoded and deflated. */ + let deflatedData = [120, 94, 99, 96, 96, 224, 114, 60, + 188, 244, 217, 158, 69, 79, 215, + 78, 87, 4, 0, 35, 207, 6, 66]; + + // How much data we are sending. + push32(data, toUnsigned32bit(-(4 + deflatedData.length))); + + data = data.concat(flags); + data = data.concat(deflatedData); + + const spy = sinon.spy(); + client.addEventListener("clipboard", spy); + + client._sock._websocket._receive_data(new Uint8Array(data)); + expect(spy).to.have.been.calledOnce; + expect(spy.args[0][0].detail.text).to.equal(expectedData); + client.removeEventListener("clipboard", spy); + }); + + it('should update clipboard with correct escape characters from a Provide message ', function () { + let expectedData = "Oh\nmy!"; + let data = [3, 0, 0, 0]; + const flags = [0x10, 0x00, 0x00, 0x01]; + + let text = encodeUTF8("Oh\r\nmy!\0"); + + let deflatedText = deflateWithSize(text); + + // How much data we are sending. + push32(data, toUnsigned32bit(-(4 + deflatedText.length))); + + data = data.concat(flags); + + let sendData = new Uint8Array(data.length + deflatedText.length); + sendData.set(data); + sendData.set(deflatedText, data.length); + + const spy = sinon.spy(); + client.addEventListener("clipboard", spy); + + client._sock._websocket._receive_data(sendData); + expect(spy).to.have.been.calledOnce; + expect(spy.args[0][0].detail.text).to.equal(expectedData); + client.removeEventListener("clipboard", spy); + }); + + }); + + describe('Handle Notify', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'extendedClipboardRequest'); + }); + + afterEach(function () { + RFB.messages.extendedClipboardRequest.restore(); + }); + + it('should make a request with supported formats when receiving a notify message', function () { + let data = [3, 0, 0, 0]; + const flags = [0x08, 0x00, 0x00, 0x07]; + push32(data, toUnsigned32bit(-4)); + data = data.concat(flags); + let expectedData = [0x01]; + + client._sock._websocket._receive_data(new Uint8Array(data)); + + expect(RFB.messages.extendedClipboardRequest).to.have.been.calledOnce; + expect(RFB.messages.extendedClipboardRequest).to.have.been.calledWith(client._sock, expectedData); + }); + }); + + describe('Handle Peek', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'extendedClipboardNotify'); + }); + + afterEach(function () { + RFB.messages.extendedClipboardNotify.restore(); + }); + + it('should send an empty Notify when receiving a Peek and no excisting clipboard data', function () { + let data = [3, 0, 0, 0]; + const flags = [0x04, 0x00, 0x00, 0x00]; + push32(data, toUnsigned32bit(-4)); + data = data.concat(flags); + let expectedData = []; + + client._sock._websocket._receive_data(new Uint8Array(data)); + + expect(RFB.messages.extendedClipboardNotify).to.have.been.calledOnce; + expect(RFB.messages.extendedClipboardNotify).to.have.been.calledWith(client._sock, expectedData); + }); + + it('should send a Notify message with supported formats when receiving a Peek', function () { + let data = [3, 0, 0, 0]; + const flags = [0x04, 0x00, 0x00, 0x00]; + push32(data, toUnsigned32bit(-4)); + data = data.concat(flags); + let expectedData = [0x01]; + + // Needed to have clipboard data to read. + // This will trigger a call to Notify, reset history + client.clipboardPasteFrom("HejHej"); + RFB.messages.extendedClipboardNotify.resetHistory(); + + client._sock._websocket._receive_data(new Uint8Array(data)); + + expect(RFB.messages.extendedClipboardNotify).to.have.been.calledOnce; + expect(RFB.messages.extendedClipboardNotify).to.have.been.calledWith(client._sock, expectedData); + }); + }); + + describe('Handle Request', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'extendedClipboardProvide'); + }); + + afterEach(function () { + RFB.messages.extendedClipboardProvide.restore(); + }); + + it('should send a Provide message with supported formats when receiving a Request', function () { + let data = [3, 0, 0, 0]; + const flags = [0x02, 0x00, 0x00, 0x01]; + push32(data, toUnsigned32bit(-4)); + data = data.concat(flags); + let expectedData = [0x01]; + + client.clipboardPasteFrom("HejHej"); + expect(RFB.messages.extendedClipboardProvide).to.not.have.been.called; + + client._sock._websocket._receive_data(new Uint8Array(data)); + + expect(RFB.messages.extendedClipboardProvide).to.have.been.calledOnce; + expect(RFB.messages.extendedClipboardProvide).to.have.been.calledWith(client._sock, expectedData, ["HejHej"]); + }); + }); + }); - client._sock._websocket._receive_data(new Uint8Array(data)); - expect(spy).to.have.been.calledOnce; - expect(spy.args[0][0].detail.text).to.equal(expected_str); }); it('should fire the bell callback on Bell', function () { @@ -2580,3 +2834,166 @@ describe('Remote Frame Buffer Protocol Client', function () { }); }); }); + +describe('RFB messages', function () { + let sock; + + before(function () { + FakeWebSocket.replace(); + sock = new Websock(); + sock.open(); + }); + + after(function () { + FakeWebSocket.restore(); + }); + + describe('Extended Clipboard Handling Send', function () { + beforeEach(function () { + sinon.spy(RFB.messages, 'clientCutText'); + }); + + afterEach(function () { + RFB.messages.clientCutText.restore(); + }); + + it('should call clientCutText with correct Caps data', function () { + let formats = { + 0: 2, + 2: 4121 + }; + let expectedData = new Uint8Array([0x1F, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x10, 0x19]); + let actions = [ + 1 << 24, // Caps + 1 << 25, // Request + 1 << 26, // Peek + 1 << 27, // Notify + 1 << 28 // Provide + ]; + + RFB.messages.extendedClipboardCaps(sock, actions, formats); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData); + }); + + it('should call clientCutText with correct Request data', function () { + let formats = new Uint8Array([0x01]); + let expectedData = new Uint8Array([0x02, 0x00, 0x00, 0x01]); + + RFB.messages.extendedClipboardRequest(sock, formats); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData); + }); + + it('should call clientCutText with correct Notify data', function () { + let formats = new Uint8Array([0x01]); + let expectedData = new Uint8Array([0x08, 0x00, 0x00, 0x01]); + + RFB.messages.extendedClipboardNotify(sock, formats); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData); + }); + + it('should call clientCutText with correct Provide data', function () { + let testText = "Test string"; + let expectedText = encodeUTF8(testText + "\0"); + + let deflatedData = deflateWithSize(expectedText); + + // Build Expected with flags and deflated data + let expectedData = new Uint8Array(4 + deflatedData.length); + expectedData[0] = 0x10; // The client capabilities + expectedData[1] = 0x00; // Reserved flags + expectedData[2] = 0x00; // Reserved flags + expectedData[3] = 0x01; // The formats client supports + expectedData.set(deflatedData, 4); + + RFB.messages.extendedClipboardProvide(sock, [0x01], [testText]); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData, true); + + }); + + describe('End of line characters', function () { + it('Carriage return', function () { + + let testText = "Hello\rworld\r\r!"; + let expectedText = encodeUTF8("Hello\r\nworld\r\n\r\n!\0"); + + let deflatedData = deflateWithSize(expectedText); + + // Build Expected with flags and deflated data + let expectedData = new Uint8Array(4 + deflatedData.length); + expectedData[0] = 0x10; // The client capabilities + expectedData[1] = 0x00; // Reserved flags + expectedData[2] = 0x00; // Reserved flags + expectedData[3] = 0x01; // The formats client supports + expectedData.set(deflatedData, 4); + + RFB.messages.extendedClipboardProvide(sock, [0x01], [testText]); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData, true); + }); + + it('Carriage return Line feed', function () { + + let testText = "Hello\r\n\r\nworld\r\n!"; + let expectedText = encodeUTF8(testText + "\0"); + + let deflatedData = deflateWithSize(expectedText); + + // Build Expected with flags and deflated data + let expectedData = new Uint8Array(4 + deflatedData.length); + expectedData[0] = 0x10; // The client capabilities + expectedData[1] = 0x00; // Reserved flags + expectedData[2] = 0x00; // Reserved flags + expectedData[3] = 0x01; // The formats client supports + expectedData.set(deflatedData, 4); + + RFB.messages.extendedClipboardProvide(sock, [0x01], [testText]); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData, true); + }); + + it('Line feed', function () { + let testText = "Hello\n\n\nworld\n!"; + let expectedText = encodeUTF8("Hello\r\n\r\n\r\nworld\r\n!\0"); + + let deflatedData = deflateWithSize(expectedText); + + // Build Expected with flags and deflated data + let expectedData = new Uint8Array(4 + deflatedData.length); + expectedData[0] = 0x10; // The client capabilities + expectedData[1] = 0x00; // Reserved flags + expectedData[2] = 0x00; // Reserved flags + expectedData[3] = 0x01; // The formats client supports + expectedData.set(deflatedData, 4); + + RFB.messages.extendedClipboardProvide(sock, [0x01], [testText]); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData, true); + }); + + it('Carriage return and Line feed mixed', function () { + let testText = "\rHello\r\n\rworld\n\n!"; + let expectedText = encodeUTF8("\r\nHello\r\n\r\nworld\r\n\r\n!\0"); + + let deflatedData = deflateWithSize(expectedText); + + // Build Expected with flags and deflated data + let expectedData = new Uint8Array(4 + deflatedData.length); + expectedData[0] = 0x10; // The client capabilities + expectedData[1] = 0x00; // Reserved flags + expectedData[2] = 0x00; // Reserved flags + expectedData[3] = 0x01; // The formats client supports + expectedData.set(deflatedData, 4); + + RFB.messages.extendedClipboardProvide(sock, [0x01], [testText]); + expect(RFB.messages.clientCutText).to.have.been.calledOnce; + expect(RFB.messages.clientCutText).to.have.been.calledWith(sock, expectedData, true); + }); + }); + }); +}); From e4e6a9b9b40545094530cf1d751b33466d2ed57d Mon Sep 17 00:00:00 2001 From: Pierre Ossman Date: Tue, 18 Feb 2020 15:24:51 +0100 Subject: [PATCH 13/19] Style all input types for consistent UI At least all that the browsers will let us. --- app/styles/base.css | 80 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/app/styles/base.css b/app/styles/base.css index 9db83bf6..d87fa4f9 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -83,8 +83,20 @@ html { * ---------------------------------------- */ -input[type=input], input[type=password], input[type=number], -input:not([type]), textarea { +input:not([type]), +input[type=date], +input[type=datetime-local], +input[type=email], +input[type=month], +input[type=number], +input[type=password], +input[type=search], +input[type=tel], +input[type=text], +input[type=time], +input[type=url], +input[type=week], +textarea { /* Disable default rendering */ -webkit-appearance: none; -moz-appearance: none; @@ -98,7 +110,11 @@ input:not([type]), textarea { background: linear-gradient(to top, rgb(255, 255, 255) 80%, rgb(240, 240, 240)); } -input[type=button], input[type=submit], select { +input[type=button], +input[type=color], +input[type=reset], +input[type=submit], +select { /* Disable default rendering */ -webkit-appearance: none; -moz-appearance: none; @@ -116,7 +132,10 @@ input[type=button], input[type=submit], select { vertical-align: middle; } -input[type=button], input[type=submit] { +input[type=button], +input[type=color], +input[type=reset], +input[type=submit] { padding-left: 20px; padding-right: 20px; } @@ -126,35 +145,72 @@ option { background: white; } -input[type=input]:focus, input[type=password]:focus, -input:not([type]):focus, input[type=button]:focus, +input:not([type]):focus, +input[type=button]:focus, +input[type=color]:focus, +input[type=date]:focus, +input[type=datetime-local]:focus, +input[type=email]:focus, +input[type=month]:focus, +input[type=number]:focus, +input[type=password]:focus, +input[type=reset]:focus, +input[type=search]:focus, input[type=submit]:focus, -textarea:focus, select:focus { +input[type=tel]:focus, +input[type=text]:focus, +input[type=time]:focus, +input[type=url]:focus, +input[type=week]:focus, +select:focus, +textarea:focus { box-shadow: 0px 0px 3px rgba(74, 144, 217, 0.5); border-color: rgb(74, 144, 217); outline: none; } input[type=button]::-moz-focus-inner, +input[type=color]::-moz-focus-inner, +input[type=reset]::-moz-focus-inner, input[type=submit]::-moz-focus-inner { border: none; } -input[type=input]:disabled, input[type=password]:disabled, -input:not([type]):disabled, input[type=button]:disabled, -input[type=submit]:disabled, input[type=number]:disabled, -textarea:disabled, select:disabled { +input:not([type]):disabled, +input[type=button]:disabled, +input[type=color]:disabled, +input[type=date]:disabled, +input[type=datetime-local]:disabled, +input[type=email]:disabled, +input[type=month]:disabled, +input[type=number]:disabled, +input[type=password]:disabled, +input[type=reset]:disabled, +input[type=search]:disabled, +input[type=submit]:disabled, +input[type=tel]:disabled, +input[type=text]:disabled, +input[type=time]:disabled, +input[type=url]:disabled, +input[type=week]:disabled, +select:disabled, +textarea:disabled { color: rgb(128, 128, 128); background: rgb(240, 240, 240); } -input[type=button]:active, input[type=submit]:active, +input[type=button]:active, +input[type=color]:active, +input[type=reset]:active, +input[type=submit]:active, select:active { border-bottom-width: 1px; margin-top: 3px; } :root:not(.noVNC_touch) input[type=button]:hover:not(:disabled), +:root:not(.noVNC_touch) input[type=color]:hover:not(:disabled), +:root:not(.noVNC_touch) input[type=reset]:hover:not(:disabled), :root:not(.noVNC_touch) input[type=submit]:hover:not(:disabled), :root:not(.noVNC_touch) select:hover:not(:disabled) { background: linear-gradient(to top, rgb(255, 255, 255), rgb(250, 250, 250)); From ceb8ef4ec1a21bc5917185d43360aa78ff735afe Mon Sep 17 00:00:00 2001 From: Alex Tanskanen Date: Thu, 20 Feb 2020 16:12:35 +0100 Subject: [PATCH 14/19] Fix crash with too large clipboard data If too much text is copied in the session, String.fromCharCode.apply() would crash in Safari on macOS and Chrome on Linux. This commit fixes this issue by avoiding apply() altogether. Also added test to cover this issue. --- core/rfb.js | 6 +++++- tests/test.rfb.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/core/rfb.js b/core/rfb.js index 9e6881fd..536ea25c 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -1504,7 +1504,11 @@ export default class RFB extends EventTargetMixin { streamInflator.setInput(null); if (textData !== null) { - textData = String.fromCharCode.apply(null, textData); + let tmpText = ""; + for (let i = 0; i < textData.length; i++) { + tmpText += String.fromCharCode(textData[i]); + } + textData = tmpText; textData = decodeUTF8(textData); if ((textData.length > 0) && "\0" === textData.charAt(textData.length - 1)) { diff --git a/tests/test.rfb.js b/tests/test.rfb.js index bb690cee..41232aee 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -2514,6 +2514,38 @@ describe('Remote Frame Buffer Protocol Client', function () { client.removeEventListener("clipboard", spy); }); + it('should be able to handle large Provide messages', function () { + // repeat() is not supported in IE so a loop is needed instead + let expectedData = "hello"; + for (let i = 1; i <= 100000; i++) { + expectedData += "hello"; + } + + let data = [3, 0, 0, 0]; + const flags = [0x10, 0x00, 0x00, 0x01]; + + let text = encodeUTF8(expectedData + "\0"); + + let deflatedText = deflateWithSize(text); + + // How much data we are sending. + push32(data, toUnsigned32bit(-(4 + deflatedText.length))); + + data = data.concat(flags); + + let sendData = new Uint8Array(data.length + deflatedText.length); + sendData.set(data); + sendData.set(deflatedText, data.length); + + const spy = sinon.spy(); + client.addEventListener("clipboard", spy); + + client._sock._websocket._receive_data(sendData); + expect(spy).to.have.been.calledOnce; + expect(spy.args[0][0].detail.text).to.equal(expectedData); + client.removeEventListener("clipboard", spy); + }); + }); describe('Handle Notify', function () { From 9253e178fcaff965ccea31b48069313568c17b40 Mon Sep 17 00:00:00 2001 From: Niko Lehto Date: Mon, 24 Feb 2020 08:57:28 +0100 Subject: [PATCH 15/19] Hide clipboard side bar button when view only mode The clipboard side bar button serves no purpose if user uses 'View Only' mode, this commit hides this button in those instances. --- app/ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/ui.js b/app/ui.js index 3f874511..0268f462 100644 --- a/app/ui.js +++ b/app/ui.js @@ -1621,6 +1621,8 @@ const UI = { .classList.add('noVNC_hidden'); document.getElementById('noVNC_mouse_button' + UI.rfb.touchButton) .classList.add('noVNC_hidden'); + document.getElementById('noVNC_clipboard_button') + .classList.add('noVNC_hidden'); } else { document.getElementById('noVNC_keyboard_button') .classList.remove('noVNC_hidden'); @@ -1628,6 +1630,8 @@ const UI = { .classList.remove('noVNC_hidden'); document.getElementById('noVNC_mouse_button' + UI.rfb.touchButton) .classList.remove('noVNC_hidden'); + document.getElementById('noVNC_clipboard_button') + .classList.remove('noVNC_hidden'); } }, From efd1f8a4f2acbd40833aa2a5fec2ed4a598e6753 Mon Sep 17 00:00:00 2001 From: Andrey Trebler Date: Mon, 10 Feb 2020 12:44:36 +0100 Subject: [PATCH 16/19] adds qualityLevel property to RFB class for updating JPEG quality level encoding on the fly --- core/rfb.js | 24 +++++++++- core/util/polyfill.js | 9 +++- docs/API.md | 5 +++ tests/test.rfb.js | 102 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/core/rfb.js b/core/rfb.js index 536ea25c..72f279a4 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -275,6 +275,8 @@ export default class RFB extends EventTargetMixin { Log.Warn("Specifying showDotCursor as a RFB constructor argument is deprecated"); this._showDotCursor = options.showDotCursor; } + + this._qualityLevel = 6; } // ===== PROPERTIES ===== @@ -337,6 +339,26 @@ export default class RFB extends EventTargetMixin { get background() { return this._screen.style.background; } set background(cssValue) { this._screen.style.background = cssValue; } + get qualityLevel() { + return this._qualityLevel; + } + set qualityLevel(qualityLevel) { + if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) { + Log.Error("qualityLevel must be an integer between 0 and 9"); + return; + } + + if (this._qualityLevel === qualityLevel) { + return; + } + + this._qualityLevel = qualityLevel; + + if (this._rfb_connection_state === 'connected') { + this._sendEncodings(); + } + } + // ===== PUBLIC METHODS ===== disconnect() { @@ -1294,7 +1316,7 @@ export default class RFB extends EventTargetMixin { encs.push(encodings.encodingRaw); // Psuedo-encoding settings - encs.push(encodings.pseudoEncodingQualityLevel0 + 6); + encs.push(encodings.pseudoEncodingQualityLevel0 + this._qualityLevel); encs.push(encodings.pseudoEncodingCompressLevel0 + 2); encs.push(encodings.pseudoEncodingDesktopSize); diff --git a/core/util/polyfill.js b/core/util/polyfill.js index 648ceebc..0e458c86 100644 --- a/core/util/polyfill.js +++ b/core/util/polyfill.js @@ -1,6 +1,6 @@ /* * noVNC: HTML5 VNC client - * Copyright (C) 2018 The noVNC Authors + * Copyright (C) 2020 The noVNC Authors * Licensed under MPL 2.0 or any later version (see LICENSE.txt) */ @@ -52,3 +52,10 @@ if (typeof Object.assign != 'function') { window.CustomEvent = CustomEvent; } })(); + +/* Number.isInteger() (taken from MDN) */ +Number.isInteger = Number.isInteger || function isInteger(value) { + return typeof value === 'number' && + isFinite(value) && + Math.floor(value) === value; +}; diff --git a/docs/API.md b/docs/API.md index aa6337fe..1b00179e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -64,6 +64,11 @@ protocol stream. to the element containing the remote session screen. The default value is `rgb(40, 40, 40)` (solid gray color). +`qualityLevel` + - Is an `int` in range `[0-9]` controlling the desired JPEG quality. + Value `0` implies low quality and `9` implies high quality. + Default value is `6`. + `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/tests/test.rfb.js b/tests/test.rfb.js index 41232aee..4e273935 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -2865,6 +2865,108 @@ describe('Remote Frame Buffer Protocol Client', function () { // error events do nothing }); }); + + describe('Quality level setting', function () { + const defaultQuality = 6; + + let client; + + beforeEach(function () { + client = make_rfb(); + sinon.spy(RFB.messages, "clientEncodings"); + }); + + afterEach(function () { + RFB.messages.clientEncodings.restore(); + }); + + it(`should equal ${defaultQuality} by default`, function () { + expect(client._qualityLevel).to.equal(defaultQuality); + }); + + it('should ignore non-integers when set', function () { + client.qualityLevel = '1'; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = 1.5; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = null; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = undefined; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = {}; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + }); + + it('should ignore integers out of range [0, 9]', function () { + client.qualityLevel = -1; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = 10; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + }); + + it('should send clientEncodings with new quality value', function () { + let newQuality; + + newQuality = 8; + client.qualityLevel = newQuality; + expect(client.qualityLevel).to.equal(newQuality); + expect(RFB.messages.clientEncodings).to.have.been.calledOnce; + expect(RFB.messages.clientEncodings.getCall(0).args[1]).to.include(encodings.pseudoEncodingQualityLevel0 + newQuality); + }); + + it('should not send clientEncodings if quality is the same', function () { + let newQuality; + + newQuality = 2; + client.qualityLevel = newQuality; + expect(RFB.messages.clientEncodings).to.have.been.calledOnce; + expect(RFB.messages.clientEncodings.getCall(0).args[1]).to.include(encodings.pseudoEncodingQualityLevel0 + newQuality); + + RFB.messages.clientEncodings.resetHistory(); + + client.qualityLevel = newQuality; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + }); + + it('should not send clientEncodings if not in connected state', function () { + let newQuality; + + client._rfb_connection_state = ''; + newQuality = 2; + client.qualityLevel = newQuality; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client._rfb_connection_state = 'connnecting'; + newQuality = 6; + client.qualityLevel = newQuality; + expect(RFB.messages.clientEncodings).to.not.have.been.called; + + RFB.messages.clientEncodings.resetHistory(); + + client._rfb_connection_state = 'connected'; + newQuality = 5; + client.qualityLevel = newQuality; + expect(RFB.messages.clientEncodings).to.have.been.calledOnce; + expect(RFB.messages.clientEncodings.getCall(0).args[1]).to.include(encodings.pseudoEncodingQualityLevel0 + newQuality); + }); + }); }); describe('RFB messages', function () { From 5243cbf61107507ee221e4848aeb412553acb3a2 Mon Sep 17 00:00:00 2001 From: Pierre Ossman Date: Fri, 28 Feb 2020 14:52:56 +0100 Subject: [PATCH 17/19] Add UI for quality setting --- app/ui.js | 17 +++++++++++++++++ docs/EMBEDDING.md | 2 ++ vnc.html | 5 +++++ 3 files changed, 24 insertions(+) diff --git a/app/ui.js b/app/ui.js index 0268f462..d118c84f 100644 --- a/app/ui.js +++ b/app/ui.js @@ -161,6 +161,7 @@ const UI = { UI.initSetting('encrypt', (window.location.protocol === "https:")); UI.initSetting('view_clip', false); UI.initSetting('resize', 'off'); + UI.initSetting('quality', 6); UI.initSetting('shared', true); UI.initSetting('view_only', false); UI.initSetting('show_dot', false); @@ -347,6 +348,8 @@ const UI = { UI.addSettingChangeHandler('resize'); UI.addSettingChangeHandler('resize', UI.applyResizeMode); UI.addSettingChangeHandler('resize', UI.updateViewClip); + UI.addSettingChangeHandler('quality'); + UI.addSettingChangeHandler('quality', UI.updateQuality); UI.addSettingChangeHandler('view_clip'); UI.addSettingChangeHandler('view_clip', UI.updateViewClip); UI.addSettingChangeHandler('shared'); @@ -829,6 +832,7 @@ const UI = { UI.updateSetting('encrypt'); UI.updateSetting('view_clip'); UI.updateSetting('resize'); + UI.updateSetting('quality'); UI.updateSetting('shared'); UI.updateSetting('view_only'); UI.updateSetting('path'); @@ -1030,6 +1034,7 @@ const UI = { UI.rfb.clipViewport = UI.getSetting('view_clip'); UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale'; UI.rfb.resizeSession = UI.getSetting('resize') === 'remote'; + UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); UI.rfb.showDotCursor = UI.getSetting('show_dot'); UI.updateViewOnly(); // requires UI.rfb @@ -1324,6 +1329,18 @@ const UI = { /* ------^------- * /VIEWDRAG * ============== + * QUALITY + * ------v------*/ + + updateQuality() { + if (!UI.rfb) return; + + UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); + }, + +/* ------^------- + * /QUALITY + * ============== * KEYBOARD * ------v------*/ diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 5399b48b..3f85f4b3 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -61,6 +61,8 @@ 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`. +* `quality` - The session JPEG quality level. Can be `0` to `9`. + * `show_dot` - If a dot cursor should be shown when the remote server provides no local cursor, or provides a fully-transparent (invisible) cursor. diff --git a/vnc.html b/vnc.html index ef7150c8..1bc88440 100644 --- a/vnc.html +++ b/vnc.html @@ -207,6 +207,11 @@
  • Advanced
      +
    • + + +
    • +

    • From c4633ab33377f690c6f5efcee7ede858a6c197db Mon Sep 17 00:00:00 2001 From: Pierre Ossman Date: Fri, 28 Feb 2020 14:56:57 +0100 Subject: [PATCH 18/19] Set a default value for the quality input --- vnc.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vnc.html b/vnc.html index 1bc88440..571ca20a 100644 --- a/vnc.html +++ b/vnc.html @@ -209,7 +209,7 @@
      • - +

      • From a040c402ed64fbbee67e69dc4787e6ea54a8a88c Mon Sep 17 00:00:00 2001 From: Alex Tanskanen Date: Thu, 12 Mar 2020 13:17:51 +0100 Subject: [PATCH 19/19] Fix focus problem after closing the toolbar Closing the toolbar would make the focus remain on the toolbar and not in the session. The only way to switch focus was to click in the session. This commit will automatically switch back focus to the session after closing the toolbar. --- app/ui.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/ui.js b/app/ui.js index d118c84f..1c6248ed 100644 --- a/app/ui.js +++ b/app/ui.js @@ -535,6 +535,7 @@ const UI = { UI.closeAllPanels(); document.getElementById('noVNC_control_bar') .classList.remove("noVNC_open"); + UI.rfb.focus(); }, toggleControlbar() {