Merge pull request #17 from kasmtech/chromeclip

Chromeclip
This commit is contained in:
mmcclaskey 2021-10-15 15:40:17 -04:00 committed by GitHub
commit 0bd3813949
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 330 additions and 74 deletions

View File

@ -30,9 +30,11 @@ window.updateSetting = (name, value) => {
}
}
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 }
import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold, supportsBinaryClipboard, isFirefox }
from '../core/util/browser.js';
import { setCapture, getPointerEvent } from '../core/util/events.js';
import KeyTable from "../core/input/keysym.js";
@ -1100,20 +1102,12 @@ const UI = {
},
clipboardReceive(e) {
if (UI.rfb.clipboardDown && UI.rfb.clipboardSeamless ) {
if (UI.rfb.clipboardDown) {
var curvalue = document.getElementById('noVNC_clipboard_text').value;
if (curvalue != e.detail.text) {
Log.Debug(">> UI.clipboardReceive: " + e.detail.text.substr(0, 40) + "...");
document.getElementById('noVNC_clipboard_text').value = e.detail.text;
Log.Debug("<< UI.clipboardReceive");
if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(e.detail.text)
.then(function () {
//UI.popupMessage("Selection Copied");
}, function () {
console.error("Failed to write system clipboard (trying to copy from NoVNC clipboard)")
});
}
}
}
},
@ -1142,26 +1136,22 @@ const UI = {
UI.copyFromLocalClipboard();
},
copyFromLocalClipboard: function copyFromLocalClipboard() {
if (!document.hasFocus()) {
Log.Debug("window does not have focus");
return;
}
if (UI.rfb && UI.rfb.clipboardUp && UI.rfb.clipboardSeamless) {
UI.readClipboard(function (text) {
var maximumBufferSize = 10000;
var clipVal = document.getElementById('noVNC_clipboard_text').value;
if (clipVal != text) {
document.getElementById('noVNC_clipboard_text').value = text; // The websocket has a maximum buffer array size
if (text.length > maximumBufferSize) {
UI.popupMessage("Clipboard contents too large. Data truncated", 2000);
UI.rfb.clipboardPasteFrom(text.slice(0, maximumBufferSize));
} else {
//UI.popupMessage("Copied from Local Clipboard");
UI.rfb.clipboardPasteFrom(text);
if (UI.rfb.clipboardBinary) {
navigator.clipboard.read().then((data) => {
if (UI.rfb) {
UI.rfb.clipboardPasteDataFrom(data);
}
} // Reset flag to prevent checking too often
UI.needToCheckClipboardChange = false;
});
UI.needToCheckClipboardChange = false;
}, (err) => {
Log.Debug("No data in clipboard");
});
}
}
},
@ -1304,6 +1294,13 @@ const UI = {
UI.rfb.clipboardUp = UI.getSetting('clipboard_up');
UI.rfb.clipboardDown = UI.getSetting('clipboard_down');
UI.rfb.clipboardSeamless = UI.getSetting('clipboard_seamless');
UI.rfb.clipboardBinary = supportsBinaryClipboard() && UI.rfb.clipboardSeamless;
//Only explicitly request permission to clipboard on browsers that support binary clipboard access
if (supportsBinaryClipboard()) {
// explicitly request permission to the clipboard
navigator.permissions.query({ name: "clipboard-read" }).then((result) => { Log.Debug('binary clipboard enabled') });
}
// KASM-960 workaround, disable seamless on Safari
if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent))
{

View File

@ -10,6 +10,7 @@
import { toUnsigned32bit, toSigned32bit } from './util/int.js';
import * as Log from './util/logging.js';
import { encodeUTF8, decodeUTF8 } from './util/strings.js';
import { hashUInt8Array } from './util/int.js';
import { dragThreshold, supportsCursorURIs, isTouchDevice, isWindows, isMac } from './util/browser.js';
import { clientToElement } from './util/element.js';
import { setCapture } from './util/events.js';
@ -140,6 +141,7 @@ export default class RFB extends EventTargetMixin {
this._frameRate = 30;
this._maxVideoResolutionX = 960;
this._maxVideoResolutionY = 540;
this._clipboardBinary = true;
this._trackFrameStats = false;
@ -332,10 +334,14 @@ export default class RFB extends EventTargetMixin {
this._qualityLevel = 6;
this._compressionLevel = 2;
this._clipHash = 0;
}
// ===== PROPERTIES =====
get clipboardBinary() { return this._clipboardMode; }
set clipboardBinary(val) { this._clipboardMode = val; }
get videoQuality() { return this._videoQuality; }
set videoQuality(quality) { this._videoQuality = quality; }
@ -763,21 +769,94 @@ export default class RFB extends EventTargetMixin {
clipboardPasteFrom(text) {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
if (!(typeof text === 'string' && text.length > 0)) { return; }
this.sentEventsCounter+=1;
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);
let data = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) {
data[i] = text.charCodeAt(i);
}
let h = hashUInt8Array(data);
if (h === this._clipHash) {
Log.Debug('No clipboard changes');
return;
} else {
this._clipHash = h;
}
let dataset = [];
let mimes = [ 'text/plain' ];
dataset.push(data);
RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes);
}
async clipboardPasteDataFrom(clipdata) {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
this.sentEventsCounter+=1;
let dataset = [];
let mimes = [];
let h = 0;
for (let i = 0; i < clipdata.length; i++) {
for (let ti = 0; ti < clipdata[i].types.length; ti++) {
let mime = clipdata[i].types[ti];
switch (mime) {
case 'image/png':
case 'text/plain':
case 'text/html':
let blob = await clipdata[i].getType(mime);
if (!blob) {
continue;
}
let buff = await blob.arrayBuffer();
let data = new Uint8Array(buff);
if (!h) {
h = hashUInt8Array(data);
if (h === this._clipHash) {
Log.Debug('No clipboard changes');
return;
} else {
this._clipHash = h;
}
}
if (mimes.includes(mime)) {
continue;
}
mimes.push(mime);
dataset.push(data);
Log.Debug('Sending mime type: ' + mime);
break;
default:
Log.Info('skipping clip send mime type: ' + mime)
}
}
}
//if png is present and text/plain is not, remove other variations of images to save bandwidth
//if png is present with text/plain, then remove png. Word will put in a png of copied text
if (mimes.includes('image/png') && !mimes.includes('text/plain')) {
let i = mimes.indexOf('image/png');
mimes = mimes.slice(i, i+1);
dataset = dataset.slice(i, i+1);
} else if (mimes.includes('image/png') && mimes.includes('text/plain')) {
let i = mimes.indexOf('image/png');
mimes.splice(i, 1);
dataset.splice(i, 1);
}
if (dataset.length > 0) {
RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes);
}
}
requestBottleneckStats() {
@ -980,7 +1059,7 @@ export default class RFB extends EventTargetMixin {
try {
if (x > 1280 && limited && this.videoQuality == 1) {
var ratio = y / x;
console.log(ratio);
Log.Debug(ratio);
x = 1280;
y = x * ratio;
}
@ -989,7 +1068,7 @@ export default class RFB extends EventTargetMixin {
y = 720;
}
} catch (err) {
console.log(err);
Log.Debug(err);
}
return { w: x,
@ -2383,6 +2462,100 @@ export default class RFB extends EventTargetMixin {
return true;
}
_handleBinaryClipboard() {
Log.Debug("HandleBinaryClipboard");
if (this._sock.rQwait("Binary Clipboard header", 2, 1)) { return false; }
let num = this._sock.rQshift8(); // how many different mime types
let mimes = [];
let clipItemData = {};
let buffByteLen = 2;
let textdata = '';
Log.Info(num + ' Clipboard items recieved.');
Log.Debug('Started clipbooard processing with Client sockjs buffer size ' + this._sock.rQlen);
for (let i = 0; i < num; i++) {
if (this._sock.rQwait("Binary Clipboard mimelen", 1, buffByteLen)) { return false; }
buffByteLen++;
let mimelen = this._sock.rQshift8();
if (this._sock.rQwait("Binary Clipboard mime", Math.abs(mimelen), buffByteLen)) { return false; }
buffByteLen+=mimelen;
let mime = this._sock.rQshiftStr(mimelen);
if (this._sock.rQwait("Binary Clipboard data len", 4, buffByteLen)) { return false; }
buffByteLen+=4;
let len = this._sock.rQshift32();
if (this._sock.rQwait("Binary Clipboard data", Math.abs(len), buffByteLen)) { return false; }
let data = this._sock.rQshiftBytes(len);
buffByteLen+=len;
switch(mime) {
case "image/png":
case "text/html":
case "text/plain":
mimes.push(mime);
if (mime == "text/plain") {
//textdata = new TextDecoder().decode(data);
for (let i = 0; i < data.length; i++) {
textdata+=String.fromCharCode(data[i]);
}
if ((textdata.length > 0) && "\0" === textdata.charAt(textdata.length - 1)) {
textdata = textdata.slice(0, -1);
}
Log.Debug("Plain text clipboard recieved and placed in text element, size: " + textdata.length);
this.dispatchEvent(new CustomEvent(
"clipboard",
{ detail: { text: textdata } })
);
}
if (!this.clipboardBinary) { continue; }
Log.Info("Processed binary clipboard of MIME " + mime + " of length " + len);
clipItemData[mime] = new Blob([data], { type: mime });
break;
default:
Log.Debug('Mime type skipped: ' + mime);
break;
}
}
Log.Debug('Finished processing binary clipboard with client sockjs buffer size ' + this._sock.rQlen);
if (Object.keys(clipItemData).length > 0) {
if (this.clipboardBinary) {
this._clipHash = 0;
navigator.clipboard.write([new ClipboardItem(clipItemData)]).then(
function() {},
function(err) {
Log.Error("Error writing to client clipboard: " + err);
// Lets try writeText
if (textdata.length > 0) {
navigator.clipboard.writeText(textdata).then(
function() {},
function(err2) {
Log.Error("Error writing text to client clipboard: " + err2);
}
);
}
}
);
}
}
return true;
}
_handle_server_stats_msg() {
this._sock.rQskipBytes(3); // Padding
const length = this._sock.rQshift32();
@ -2390,8 +2563,8 @@ export default class RFB extends EventTargetMixin {
const text = this._sock.rQshiftStr(length);
console.log("Received KASM bottleneck stats:");
console.log(text);
Log.Debug("Received KASM bottleneck stats:");
Log.Debug(text);
this.dispatchEvent(new CustomEvent(
"bottleneck_stats",
{ detail: { text: text } }));
@ -2523,6 +2696,9 @@ export default class RFB extends EventTargetMixin {
this._trackFrameStats = true;
return true;
case 180: // KASM binary clipboard
return this._handleBinaryClipboard();
case 248: // ServerFence
return this._handleServerFenceMsg();
@ -3184,6 +3360,61 @@ RFB.messages = {
},
sendBinaryClipboard(sock, dataset, mimes) {
const buff = sock._sQ;
let offset = sock._sQlen;
buff[offset] = 180; // msg-type
buff[offset + 1] = dataset.length; // how many mime types
sock._sQlen += 2;
offset += 2;
for (let i=0; i < dataset.length; i++) {
let mime = mimes[i];
let data = dataset[i];
buff[offset++] = mime.length;
for (let i = 0; i < mime.length; i++) {
buff[offset++] = mime.charCodeAt(i); // change to [] if not a string
}
let length = data.length;
Log.Info('Clipboard data sent mime type ' + mime + ' len ' + length);
buff[offset++] = length >> 24;
buff[offset++] = length >> 16;
buff[offset++] = length >> 8;
buff[offset++] = length;
sock._sQlen += 1 + mime.length + 4;
// We have to keep track of from where in the data we begin creating the
// buffer for the flush in the next iteration.
let dataOffset = 0;
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] = data[dataOffset + i];
}
sock._sQlen += flushSize;
sock.flush();
remaining -= flushSize;
dataOffset += flushSize;
}
offset = sock._sQlen;
}
},
setDesktopSize(sock, width, height, id, flags) {
const buff = sock._sQ;
const offset = sock._sQlen;

View File

@ -101,3 +101,9 @@ export function isFirefox() {
return navigator && !!(/firefox/i).exec(navigator.userAgent);
}
export function supportsBinaryClipboard() {
//Safari does support the clipbaord API but has a lot of security restrictions
if (isSafari()) { return false; }
return (navigator.clipboard && typeof navigator.clipboard.read === "function");
}

View File

@ -13,3 +13,14 @@ export function toUnsigned32bit(toConvert) {
export function toSigned32bit(toConvert) {
return toConvert | 0;
}
/*
* Fast hashing function with low entropy, not for security uses.
*/
export function hashUInt8Array(data) {
let h;
for (let i = 0; i < data.length; i++) {
h = Math.imul(31, h) + data[i] | 0;
}
return h;
}

54
package-lock.json generated
View File

@ -22,7 +22,7 @@
"chai": "*",
"clean-webpack-plugin": "^3.0.0",
"commander": "*",
"core-js": "*",
"core-js": "^3.18.3",
"css-loader": "^5.0.1",
"css-minimizer-webpack-plugin": "^1.1.5",
"es-module-loader": "*",
@ -48,6 +48,7 @@
"po2json": "*",
"postcss-loader": "^4.1.0",
"preload-webpack-plugin": "^3.0.0-beta.4",
"regenerator-runtime": "^0.13.9",
"requirejs": "*",
"rollup": "*",
"rollup-plugin-node-resolve": "*",
@ -4010,9 +4011,9 @@
}
},
"node_modules/core-js": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.16.2.tgz",
"integrity": "sha512-P0KPukO6OjMpjBtHSceAZEWlDD1M2Cpzpg6dBbrjFqFhBHe/BwhxaP820xKOjRn/lZRQirrCusIpLS/n2sgXLQ==",
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.18.3.tgz",
"integrity": "sha512-tReEhtMReZaPFVw7dajMx0vlsz3oOb8ajgPoHVYGxr8ErnZ6PcYEvvmjGmXlfpnxpkYSdOQttjB+MvVbCGfvLw==",
"dev": true,
"hasInstallScript": true,
"funding": {
@ -18310,7 +18311,8 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true
"dev": true,
"requires": {}
},
"acorn-node": {
"version": "1.8.2",
@ -18364,13 +18366,15 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true
"dev": true,
"requires": {}
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
"dev": true,
"requires": {}
},
"alphanum-sort": {
"version": "1.0.2",
@ -18684,7 +18688,8 @@
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz",
"integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==",
"dev": true
"dev": true,
"requires": {}
},
"babylon": {
"version": "6.18.0",
@ -19806,9 +19811,9 @@
"dev": true
},
"core-js": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.16.2.tgz",
"integrity": "sha512-P0KPukO6OjMpjBtHSceAZEWlDD1M2Cpzpg6dBbrjFqFhBHe/BwhxaP820xKOjRn/lZRQirrCusIpLS/n2sgXLQ==",
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.18.3.tgz",
"integrity": "sha512-tReEhtMReZaPFVw7dajMx0vlsz3oOb8ajgPoHVYGxr8ErnZ6PcYEvvmjGmXlfpnxpkYSdOQttjB+MvVbCGfvLw==",
"dev": true
},
"core-js-compat": {
@ -20819,7 +20824,8 @@
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"dev": true
"dev": true,
"requires": {}
}
}
},
@ -22436,7 +22442,8 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
"integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
"dev": true
"dev": true,
"requires": {}
},
"ieee754": {
"version": "1.2.1",
@ -23347,19 +23354,22 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/karma-safari-launcher/-/karma-safari-launcher-1.0.0.tgz",
"integrity": "sha1-lpgqLMR9BmquccVTursoMZEVos4=",
"dev": true
"dev": true,
"requires": {}
},
"karma-script-launcher": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/karma-script-launcher/-/karma-script-launcher-1.0.0.tgz",
"integrity": "sha1-zQF8TeXvCeWp2nkydhdhCN1LVC0=",
"dev": true
"dev": true,
"requires": {}
},
"karma-sinon-chai": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/karma-sinon-chai/-/karma-sinon-chai-2.0.2.tgz",
"integrity": "sha512-SDgh6V0CUd+7ruL1d3yG6lFzmJNGRNQuEuCYXLaorruNP9nwQfA7hpsp4clx4CbOo5Gsajh3qUOT7CrVStUKMw==",
"dev": true
"dev": true,
"requires": {}
},
"kew": {
"version": "0.7.0",
@ -25647,7 +25657,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
"integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
"dev": true
"dev": true,
"requires": {}
},
"postcss-modules-local-by-default": {
"version": "4.0.0",
@ -26312,7 +26323,8 @@
"version": "3.0.0-beta.4",
"resolved": "https://registry.npmjs.org/preload-webpack-plugin/-/preload-webpack-plugin-3.0.0-beta.4.tgz",
"integrity": "sha512-6hhh0AswCbp/U4EPVN4fbK2wiDkXhmgjjgEYEmXa21UYwjYzCIgh3ZRMXM21ZPLfbQGpdFuSL3zFslU+edjpwg==",
"dev": true
"dev": true,
"requires": {}
},
"prelude-ls": {
"version": "1.2.1",
@ -27246,7 +27258,8 @@
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-2.14.0.tgz",
"integrity": "sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==",
"dev": true
"dev": true,
"requires": {}
},
"slash": {
"version": "2.0.0",
@ -29645,7 +29658,8 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.0.tgz",
"integrity": "sha512-uYhVJ/m9oXwEI04iIVmgLmugh2qrZihkywG9y5FfZV2ATeLIzHf93qs+tUNqlttbQK957/VX3mtwAS+UfIwA4g==",
"dev": true
"dev": true,
"requires": {}
},
"xml-name-validator": {
"version": "3.0.0",

View File

@ -30,29 +30,27 @@
"url": "git+https://github.com/kasmtech/noVNC.git"
},
"author": "Kasm Technologies (https://www.kasmweb.com)",
"contributors": [
],
"contributors": [],
"license": "MPL-2.0",
"bugs": {
"url": "https://github.com/kasmtech/noVNC/issues"
},
"homepage": "https://github.com/kasmtech/noVNC",
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/cli": "*",
"@babel/core": "*",
"babel-loader": "^8.2.2",
"@babel/plugin-syntax-dynamic-import": "*",
"@babel/plugin-transform-modules-commonjs": "*",
"@babel/preset-env": "*",
"@babel/cli": "*",
"@chiragrupani/karma-chromium-edge-launcher": "*",
"babel-loader": "^8.2.2",
"babel-plugin-import-redirect": "*",
"browserify": "*",
"babelify": "*",
"core-js": "*",
"browserify": "*",
"chai": "*",
"clean-webpack-plugin": "^3.0.0",
"commander": "*",
"core-js": "^3.18.3",
"css-loader": "^5.0.1",
"css-minimizer-webpack-plugin": "^1.1.5",
"es-module-loader": "*",
@ -64,11 +62,10 @@
"html-webpack-plugin": "^4.5.0",
"jsdom": "*",
"karma": "*",
"karma-mocha": "*",
"karma-chrome-launcher": "*",
"@chiragrupani/karma-chromium-edge-launcher": "*",
"karma-firefox-launcher": "*",
"karma-ie-launcher": "*",
"karma-mocha": "*",
"karma-mocha-reporter": "*",
"karma-safari-launcher": "*",
"karma-script-launcher": "*",
@ -79,6 +76,7 @@
"po2json": "*",
"postcss-loader": "^4.1.0",
"preload-webpack-plugin": "^3.0.0-beta.4",
"regenerator-runtime": "^0.13.9",
"requirejs": "*",
"rollup": "*",
"rollup-plugin-node-resolve": "*",
@ -90,7 +88,6 @@
"webpack": "^4.29.6",
"webpack-cli": "^3.2.3"
},
"dependencies": {},
"keywords": [
"vnc",
"rfb",