novnc
Version:
An HTML5 VNC client
1,394 lines (1,188 loc) • 151 kB
JavaScript
/*
* KasmVNC: HTML5 VNC client
* Copyright (C) 2020 Kasm Technologies
* 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 { encodeUTF8, decodeUTF8 } from './util/strings.js';
import { hashUInt8Array } from './util/int.js';
import { dragThreshold, supportsCursorURIs, isTouchDevice, isWindows, isMac, isIOS } from './util/browser.js';
import { clientToElement } from './util/element.js';
import { setCapture } from './util/events.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 GestureHandler from "./input/gesturehandler.js";
import Cursor from "./util/cursor.js";
import Websock from "./websock.js";
import DES from "./des.js";
import KeyTable from "./input/keysym.js";
import XtScancode from "./input/xtscancodes.js";
import { encodings } from "./encodings.js";
import { MouseButtonMapper, xvncButtonToMask } from "./mousebuttonmapper.js";
import RawDecoder from "./decoders/raw.js";
import CopyRectDecoder from "./decoders/copyrect.js";
import RREDecoder from "./decoders/rre.js";
import HextileDecoder from "./decoders/hextile.js";
import TightDecoder from "./decoders/tight.js";
import TightPNGDecoder from "./decoders/tightpng.js";
import UDPDecoder from './decoders/udp.js';
import { toSignedRelative16bit } from './util/int.js';
// How many seconds to wait for a disconnect to finish
const DISCONNECT_TIMEOUT = 3;
const DEFAULT_BACKGROUND = 'rgb(40, 40, 40)';
var _videoQuality = 2;
var _enableWebP = false;
var _enableQOI = false;
// Minimum wait (ms) between two mouse moves
const MOUSE_MOVE_DELAY = 17;
// Wheel thresholds
let WHEEL_LINE_HEIGHT = 19; // Pixels for one line step (on Windows)
// Gesture thresholds
const GESTURE_ZOOMSENS = 75;
const GESTURE_SCRLSENS = 50;
const DOUBLE_TAP_TIMEOUT = 1000;
const DOUBLE_TAP_THRESHOLD = 50;
// 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, touchInput, urlOrChannel, options) {
if (!target) {
throw new Error("Must specify target");
}
if (!urlOrChannel) {
throw new Error("Must specify URL, WebSocket or RTCDataChannel");
}
super();
this._target = target;
if (typeof urlOrChannel === "string") {
this._url = urlOrChannel;
} else {
this._url = null;
this._rawChannel = urlOrChannel;
}
// Connection details
options = options || {};
this._rfbCredentials = options.credentials || {};
this._shared = 'shared' in options ? !!options.shared : true;
this._repeaterID = options.repeaterID || '';
this._wsProtocols = options.wsProtocols || ['binary'];
// Internal state
this._rfbConnectionState = '';
this._rfbInitState = '';
this._rfbAuthScheme = -1;
this._rfbCleanDisconnect = true;
// Server capabilities
this._rfbVersion = 0;
this._rfbMaxVersion = 3.8;
this._rfbTightVNC = false;
this._rfbVeNCryptState = 0;
this._rfbXvpVer = 0;
this._fbWidth = 0;
this._fbHeight = 0;
this._fbName = "";
this._capabilities = { power: false };
this._supportsFence = false;
this._supportsContinuousUpdates = false;
this._enabledContinuousUpdates = false;
this._supportsSetDesktopSize = false;
this._screenID = 0;
this._screenFlags = 0;
this._qemuExtKeyEventSupported = false;
// kasm defaults
this._jpegVideoQuality = 5;
this._webpVideoQuality = 5;
this._treatLossless = 7;
this._preferBandwidth = true;
this._dynamicQualityMin = 3;
this._dynamicQualityMax = 9;
this._videoArea = 65;
this._videoTime = 5;
this._videoOutTime = 3;
this._videoScaling = 2;
this._frameRate = 30;
this._maxVideoResolutionX = 960;
this._maxVideoResolutionY = 540;
this._clipboardBinary = true;
this._useUdp = true;
this._enableQOI = false;
this.TransitConnectionStates = {
Tcp: Symbol("tcp"),
Udp: Symbol("udp"),
Upgrading: Symbol("upgrading"),
Downgrading: Symbol("downgrading"),
Failure: Symbol("failure")
}
this._transitConnectionState = this.TransitConnectionStates.Tcp;
this._lastTransition = null;
this._udpConnectFailures = 0; //Failures in upgrading connection to udp
this._udpTransitFailures = 0; //Failures in transit after successful upgrade
this._trackFrameStats = false;
this._clipboardText = null;
this._clipboardServerCapabilitiesActions = {};
this._clipboardServerCapabilitiesFormats = {};
// Internal objects
this._sock = null; // Websock object
this._display = null; // Display object
this._flushing = false; // Display flushing state
this._keyboard = null; // Keyboard input handler object
this._gestures = null; // Gesture input handler object
// Timers
this._disconnTimer = null; // disconnection timer
this._resizeTimeout = null; // resize rate limiting
this._mouseMoveTimer = null;
// Decoder states
this._decoders = {};
this._FBU = {
rects: 0, // current rect number
x: 0,
y: 0,
width: 0,
height: 0,
encoding: null,
frame_id: 0,
rect_total: 0, //Total rects in frame
};
// Mouse state
this._mousePos = {};
this._mouseButtonMask = 0;
this._mouseLastMoveTime = 0;
this._pointerLock = false;
this._pointerLockPos = { x: 0, y: 0 };
this._pointerRelativeEnabled = false;
this._mouseLastPinchAndZoomTime = 0;
this._viewportDragging = false;
this._viewportDragPos = {};
this._viewportHasMoved = false;
this._accumulatedWheelDeltaX = 0;
this._accumulatedWheelDeltaY = 0;
this.mouseButtonMapper = null;
// Gesture state
this._gestureLastTapTime = null;
this._gestureFirstDoubleTapEv = null;
this._gestureLastMagnitudeX = 0;
this._gestureLastMagnitudeY = 0;
// Bound event handlers
this._eventHandlers = {
updateHiddenKeyboard: this._updateHiddenKeyboard.bind(this),
focusCanvas: this._focusCanvas.bind(this),
windowResize: this._windowResize.bind(this),
handleMouse: this._handleMouse.bind(this),
handlePointerLockChange: this._handlePointerLockChange.bind(this),
handlePointerLockError: this._handlePointerLockError.bind(this),
handleWheel: this._handleWheel.bind(this),
handleGesture: this._handleGesture.bind(this),
};
// main setup
Log.Debug(">> RFB.constructor");
// Create DOM elements
this._screen = document.createElement('div');
this._screen.style.display = 'flex';
this._screen.style.width = '100%';
this._screen.style.height = '100%';
this._screen.style.overflow = 'auto';
this._screen.style.background = DEFAULT_BACKGROUND;
this._canvas = document.createElement('canvas');
this._canvas.style.margin = 'auto';
// Some browsers add an outline on focus
this._canvas.style.outline = 'none';
this._canvas.width = 0;
this._canvas.height = 0;
this._canvas.tabIndex = -1;
this._canvas.overflow = 'hidden';
this._screen.appendChild(this._canvas);
// 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;
// NB: nothing that needs explicit teardown should be done
// before this point, since this can throw an exception
try {
this._display = new Display(this._canvas);
} catch (exc) {
Log.Error("Display exception: " + exc);
throw exc;
}
this._display.onflush = this._onFlush.bind(this);
// populate decoder array with objects
this._decoders[encodings.encodingRaw] = new RawDecoder();
this._decoders[encodings.encodingCopyRect] = new CopyRectDecoder();
this._decoders[encodings.encodingRRE] = new RREDecoder();
this._decoders[encodings.encodingHextile] = new HextileDecoder();
this._decoders[encodings.encodingTight] = new TightDecoder(this._display);
this._decoders[encodings.encodingTightPNG] = new TightPNGDecoder();
this._decoders[encodings.encodingUDP] = new UDPDecoder();
this._keyboard = new Keyboard(this._canvas, touchInput);
this._keyboard.onkeyevent = this._handleKeyEvent.bind(this);
this._gestures = new GestureHandler();
this._sock = new Websock();
this._sock.on('message', () => {
this._handleMessage();
});
this._sock.on('open', () => {
if ((this._rfbConnectionState === 'connecting') &&
(this._rfbInitState === '')) {
this._rfbInitState = 'ProtocolVersion';
Log.Debug("Starting VNC handshake");
} else {
this._fail("Unexpected server connection while " +
this._rfbConnectionState);
}
});
this._sock.on('close', (e) => {
Log.Debug("WebSocket on-close event");
let msg = "";
if (e.code) {
msg = "(code: " + e.code;
if (e.reason) {
msg += ", reason: " + e.reason;
}
msg += ")";
}
switch (this._rfbConnectionState) {
case 'connecting':
this._fail("Connection closed " + msg);
break;
case 'connected':
// Handle disconnects that were initiated server-side
this._updateConnectionState('disconnecting');
this._updateConnectionState('disconnected');
break;
case 'disconnecting':
// Normal disconnection path
this._updateConnectionState('disconnected');
break;
case 'disconnected':
this._fail("Unexpected server disconnect " +
"when already disconnected " + msg);
break;
default:
this._fail("Unexpected server disconnect before connecting " +
msg);
break;
}
this._sock.off('close');
// Delete reference to raw channel to allow cleanup.
this._rawChannel = null;
});
this._sock.on('error', e => Log.Warn("WebSocket on-error event"));
// Slight delay of the actual connection so that the caller has
// time to set up callbacks
setTimeout(this._updateConnectionState.bind(this, 'connecting'));
Log.Debug("<< RFB.constructor");
// ===== PROPERTIES =====
this.dragViewport = false;
this.focusOnClick = true;
this.lastActiveAt = Date.now();
this._viewOnly = false;
this._clipViewport = false;
this._scaleViewport = false;
this._resizeSession = false;
this._showDotCursor = false;
if (options.showDotCursor !== undefined) {
Log.Warn("Specifying showDotCursor as a RFB constructor argument is deprecated");
this._showDotCursor = options.showDotCursor;
}
this._qualityLevel = 6;
this._compressionLevel = 2;
this._clipHash = 0;
}
// ===== PROPERTIES =====
get pointerLock() { return this._pointerLock; }
set pointerLock(value) {
if (!this._pointerLock) {
if (this._canvas.requestPointerLock) {
this._canvas.requestPointerLock();
this._pointerLockChanging = true;
} else if (this._canvas.mozRequestPointerLock) {
this._canvas.mozRequestPointerLock();
this._pointerLockChanging = true;
}
} else {
if (window.document.exitPointerLock) {
window.document.exitPointerLock();
this._pointerLockChanging = true;
} else if (window.document.mozExitPointerLock) {
window.document.mozExitPointerLock();
this._pointerLockChanging = true;
}
}
}
get pointerRelative() { return this._pointerRelativeEnabled; }
set pointerRelative(value)
{
this._pointerRelativeEnabled = value;
if (value) {
let max_w = ((this._display.scale === 1) ? this._fbWidth : (this._fbWidth * this._display.scale));
let max_h = ((this._display.scale === 1) ? this._fbHeight : (this._fbHeight * this._display.scale));
this._pointerLockPos.x = Math.floor(max_w / 2);
this._pointerLockPos.y = Math.floor(max_h / 2);
// reset the cursor position to center
this._mousePos = { x: this._pointerLockPos.x , y: this._pointerLockPos.y };
this._cursor.move(this._pointerLockPos.x, this._pointerLockPos.y);
}
}
get keyboard() { return this._keyboard; }
get clipboardBinary() { return this._clipboardMode; }
set clipboardBinary(val) { this._clipboardMode = val; }
get videoQuality() { return this._videoQuality; }
set videoQuality(quality)
{
//if changing to or from a video quality mode that uses a fixed resolution server side
if (this._videoQuality <= 1 || quality <= 1) {
this._pendingApplyResolutionChange = true;
}
this._videoQuality = quality;
this._pendingApplyEncodingChanges = true;
}
get preferBandwidth() { return this._preferBandwidth; }
set preferBandwidth(val) {
this._preferBandwidth = val;
this._pendingApplyEncodingChanges = true;
}
get viewOnly() { return this._viewOnly; }
set viewOnly(viewOnly) {
this._viewOnly = viewOnly;
if (this._rfbConnectionState === "connecting" ||
this._rfbConnectionState === "connected") {
if (viewOnly) {
this._keyboard.ungrab();
} else {
this._keyboard.grab();
}
}
}
get capabilities() { return this._capabilities; }
get touchButton() { return 0; }
set touchButton(button) { Log.Warn("Using old API!"); }
get clipViewport() { return this._clipViewport; }
set clipViewport(viewport) {
this._clipViewport = viewport;
this._updateClip();
}
get scaleViewport() { return this._scaleViewport; }
set scaleViewport(scale) {
this._scaleViewport = scale;
// Scaling trumps clipping, so we may need to adjust
// clipping when enabling or disabling scaling
if (scale && this._clipViewport) {
this._updateClip();
}
this._updateScale();
if (!scale && this._clipViewport) {
this._updateClip();
}
}
get resizeSession() { return this._resizeSession; }
set resizeSession(resize) {
this._resizeSession = resize;
if (resize) {
this._requestRemoteResize();
this.scaleViewport = true;
}
}
get showDotCursor() { return this._showDotCursor; }
set showDotCursor(show) {
this._showDotCursor = show;
this._refreshCursor();
}
get background() { return this._screen.style.background; }
set background(cssValue) { this._screen.style.background = cssValue; }
get enableWebP() { return this._enableWebP; }
set enableWebP(enabled) {
if (this._enableWebP === enabled) {
return;
}
this._enableWebP = enabled;
this._pendingApplyEncodingChanges = true;
}
get enableQOI() { return this._enableQOI; }
set enableQOI(enabled) {
if(this._enableQOI === enabled) {
return;
}
if (enabled) {
if (!this._decoders[encodings.encodingTight].enableQOI()) {
//enabling qoi failed
return;
}
}
this._enableQOI = enabled;
this._pendingApplyEncodingChanges = true;
}
get antiAliasing() { return this._display.antiAliasing; }
set antiAliasing(value) {
this._display.antiAliasing = value;
}
get jpegVideoQuality() { return this._jpegVideoQuality; }
set jpegVideoQuality(qualityLevel) {
if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
Log.Error("qualityLevel must be an integer between 0 and 9");
return;
}
if (this._jpegVideoQuality === qualityLevel) {
return;
}
this._jpegVideoQuality = qualityLevel;
this._pendingApplyEncodingChanges = true;
}
get webpVideoQuality() { return this._webpVideoQuality; }
set webpVideoQuality(qualityLevel) {
if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
Log.Error("qualityLevel must be an integer between 0 and 9");
return;
}
if (this._webpVideoQuality === qualityLevel) {
return;
}
this._webpVideoQuality = qualityLevel;
this._pendingApplyEncodingChanges = true;
}
get treatLossless() { return this._treatLossless; }
set treatLossless(qualityLevel) {
if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
Log.Error("qualityLevel must be an integer between 0 and 9");
return;
}
if (this._treatLossless === qualityLevel) {
return;
}
this._treatLossless = qualityLevel;
}
get dynamicQualityMin() { return this._dynamicQualityMin; }
set dynamicQualityMin(qualityLevel) {
if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
Log.Error("qualityLevel must be an integer between 0 and 9");
return;
}
if (this._dynamicQualityMin === qualityLevel) {
return;
}
this._dynamicQualityMin = qualityLevel;
this._pendingApplyEncodingChanges = true;
}
get dynamicQualityMax() { return this._dynamicQualityMax; }
set dynamicQualityMax(qualityLevel) {
if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
Log.Error("qualityLevel must be an integer between 0 and 9");
return;
}
if (this._dynamicQualityMax === qualityLevel) {
return;
}
this._dynamicQualityMax = qualityLevel;
this._pendingApplyEncodingChanges = true;
}
get videoArea() {
return this._videoArea;
}
set videoArea(area) {
if (!Number.isInteger(area) || area < 0 || area > 100) {
Log.Error("video area must be an integer between 0 and 100");
return;
}
if (this._videoArea === area) {
return;
}
this._videoArea = area;
this._pendingApplyEncodingChanges = true;
}
get videoTime() {
return this._videoTime;
}
set videoTime(value) {
if (!Number.isInteger(value) || value < 0 || value > 100) {
Log.Error("video time must be an integer between 0 and 100");
return;
}
if (this._videoTime === value) {
return;
}
this._videoTime = value;
this._pendingApplyEncodingChanges = true;
}
get videoOutTime() {
return this._videoOutTime;
}
set videoOutTime(value) {
if (!Number.isInteger(value) || value < 0 || value > 100) {
Log.Error("video out time must be an integer between 0 and 100");
return;
}
if (this._videoOutTime === value) {
return;
}
this._videoOutTime = value;
this._pendingApplyEncodingChanges = true;
}
get videoScaling() {
return this._videoScaling;
}
set videoScaling(value) {
if (!Number.isInteger(value) || value < 0 || value > 2) {
Log.Error("video scaling must be an integer between 0 and 2");
return;
}
if (this._videoScaling === value) {
return;
}
this._videoScaling = value;
this._pendingApplyEncodingChanges = true;
}
get frameRate() { return this._frameRate; }
set frameRate(value) {
if (!Number.isInteger(value) || value < 1 || value > 120) {
Log.Error("frame rate must be an integer between 1 and 120");
return;
}
if (this._frameRate === value) {
return;
}
this._frameRate = value;
this._pendingApplyEncodingChanges = true;
}
get maxVideoResolutionX() { return this._maxVideoResolutionX; }
set maxVideoResolutionX(value) {
if (!Number.isInteger(value) || value < 100 ) {
Log.Error("max video resolution must be an integer greater than 100");
return;
}
if (this._maxVideoResolutionX === value) {
return;
}
this._maxVideoResolutionX = value;
this._pendingApplyVideoRes = true;
}
get maxVideoResolutionY() { return this._maxVideoResolutionY; }
set maxVideoResolutionY(value) {
if (!Number.isInteger(value) || value < 100 ) {
Log.Error("max video resolution must be an integer greater than 100");
return;
}
if (this._maxVideoResolutionY === value) {
return;
}
this._maxVideoResolutionY = value;
this._pendingApplyVideoRes = true;
}
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;
this._pendingApplyEncodingChanges = true;
}
get compressionLevel() {
return this._compressionLevel;
}
set compressionLevel(compressionLevel) {
if (!Number.isInteger(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
Log.Error("compressionLevel must be an integer between 0 and 9");
return;
}
if (this._compressionLevel === compressionLevel) {
return;
}
this._compressionLevel = compressionLevel;
if (this._rfbConnectionState === 'connected') {
this._sendEncodings();
}
}
get statsFps() { return this._display.fps; }
get enableWebRTC() { return this._useUdp; }
set enableWebRTC(value) {
this._useUdp = value;
if (!value) {
if (this._rfbConnectionState === 'connected' && (this._transitConnectionState !== this.TransitConnectionStates.Tcp)) {
this._sendUdpDowngrade();
}
} else {
if (this._rfbConnectionState === 'connected' && (this._transitConnectionState !== this.TransitConnectionStates.Udp)) {
this._sendUdpUpgrade();
}
}
}
// ===== PUBLIC METHODS =====
/*
This function must be called after changing any properties that effect rendering quality
*/
updateConnectionSettings() {
if (this._rfbConnectionState === 'connected') {
if (this._pendingApplyVideoRes) {
RFB.messages.setMaxVideoResolution(this._sock, this._maxVideoResolutionX, this._maxVideoResolutionY);
}
if (this._pendingApplyResolutionChange) {
this._requestRemoteResize();
}
if (this._pendingApplyEncodingChanges) {
this._sendEncodings();
}
this._pendingApplyVideoRes = false;
this._pendingApplyEncodingChanges = false;
this._pendingApplyResolutionChange = false;
}
}
disconnect() {
this._updateConnectionState('disconnecting');
this._sock.off('error');
this._sock.off('message');
this._sock.off('open');
}
sendCredentials(creds) {
this._rfbCredentials = creds;
setTimeout(this._initMsg.bind(this), 0);
}
sendCtrlAltDel() {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
Log.Info("Sending Ctrl-Alt-Del");
this.sendKey(KeyTable.XK_Control_L, "ControlLeft", true);
this.sendKey(KeyTable.XK_Alt_L, "AltLeft", true);
this.sendKey(KeyTable.XK_Delete, "Delete", true);
this.sendKey(KeyTable.XK_Delete, "Delete", false);
this.sendKey(KeyTable.XK_Alt_L, "AltLeft", false);
this.sendKey(KeyTable.XK_Control_L, "ControlLeft", false);
}
machineShutdown() {
this._xvpOp(1, 2);
}
machineReboot() {
this._xvpOp(1, 3);
}
machineReset() {
this._xvpOp(1, 4);
}
// Send a key press. If 'down' is not specified then send a down key
// followed by an up key.
sendKey(keysym, code, down) {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
if (code !== null) {
this._setLastActive();
}
if (down === undefined) {
this.sendKey(keysym, code, true);
this.sendKey(keysym, code, false);
return;
}
const scancode = XtScancode[code];
if (this._qemuExtKeyEventSupported && scancode) {
// 0 is NoSymbol
keysym = keysym || 0;
Log.Info("Sending key (" + (down ? "down" : "up") + "): keysym " + keysym + ", scancode " + scancode);
RFB.messages.QEMUExtendedKeyEvent(this._sock, keysym, down, scancode);
} else {
if (!keysym) {
return;
}
Log.Info("Sending keysym (" + (down ? "down" : "up") + "): " + keysym);
RFB.messages.keyEvent(this._sock, keysym, down ? 1 : 0);
}
}
focus() {
this._keyboard.focus();
}
blur() {
this._keyboard.blur();
}
checkLocalClipboard() {
if (this.clipboardUp && this.clipboardSeamless) {
if (this.clipboardBinary) {
navigator.clipboard.read().then((data) => {
this.clipboardPasteDataFrom(data);
}, (err) => {
Log.Debug("No data in clipboard: " + err);
});
} else {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function (text) {
this.clipboardPasteFrom(text);
}.bind(this)).catch(function () {
return Log.Debug("Failed to read system clipboard");
});
}
}
}
}
clipboardPasteFrom(text) {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
if (!(typeof text === 'string' && text.length > 0)) { return; }
let data = new TextEncoder().encode(text);
let h = hashUInt8Array(data);
// avoid resending the same data if larger than 64k
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; }
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);
// avoid resending the same data if larger than 64k
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() {
RFB.messages.requestStats(this._sock);
}
// ===== PRIVATE METHODS =====
_setLastActive() {
this.lastActiveAt = Date.now();
}
_changeTransitConnectionState(value) {
Log.Info("Transit state change from " + this._transitConnectionState.toString() + ' to ' + value.toString());
this._transitConnectionState = value;
}
_connect() {
Log.Debug(">> RFB.connect");
if (this._url) {
try {
Log.Info(`connecting to ${this._url}`);
this._sock.open(this._url, this._wsProtocols);
this._setLastActive();
} catch (e) {
if (e.name === 'SyntaxError') {
this._fail("Invalid host or port (" + e + ")");
} else {
this._fail("Error when opening socket (" + e + ")");
}
}
} else {
try {
Log.Info(`attaching ${this._rawChannel} to Websock`);
this._sock.attach(this._rawChannel);
} catch (e) {
this._fail("Error attaching channel (" + e + ")");
}
}
// Make our elements part of the page
this._target.appendChild(this._screen);
this._gestures.attach(this._canvas);
this._cursor.attach(this._canvas);
this._refreshCursor();
// Monitor size changes of the screen
// FIXME: Use ResizeObserver, or hidden overflow
window.addEventListener('resize', this._eventHandlers.windowResize);
// Always grab focus on some kind of click event
this._canvas.addEventListener("mousedown", this._eventHandlers.focusCanvas);
this._canvas.addEventListener("touchstart", this._eventHandlers.focusCanvas);
// In order for the keyboard to not occlude the input being edited
// we move the hidden input we use for triggering the keyboard to the last click
// position which should trigger a page being moved down enough
// to show the input. On Android the whole website gets resized so we don't
// have to do anything.
if (isIOS()) {
this._canvas.addEventListener("touchend", this._eventHandlers.updateHiddenKeyboard);
}
// Mouse events
this._canvas.addEventListener('mousedown', this._eventHandlers.handleMouse);
this._canvas.addEventListener('mouseup', this._eventHandlers.handleMouse);
this._canvas.addEventListener('mousemove', this._eventHandlers.handleMouse);
// Prevent middle-click pasting (see handler for why we bind to document)
this._canvas.addEventListener('click', this._eventHandlers.handleMouse);
// preventDefault() on mousedown doesn't stop this event for some
// reason so we have to explicitly block it
this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
// Pointer Lock listeners need to be installed in document instead of the canvas.
if (document.onpointerlockchange !== undefined) {
document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError, false);
} else if (document.onmozpointerlockchange !== undefined) {
document.addEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError, false);
}
// Wheel events
this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
// Gesture events
this._canvas.addEventListener("gesturestart", this._eventHandlers.handleGesture);
this._canvas.addEventListener("gesturemove", this._eventHandlers.handleGesture);
this._canvas.addEventListener("gestureend", this._eventHandlers.handleGesture);
// WebRTC UDP datachannel inits
{
this._udpBuffer = new Map();
this._udpPeer = new RTCPeerConnection({
iceServers: [{
urls: ["stun:stun.l.google.com:19302"]
}]
});
let peer = this._udpPeer;
peer.onicecandidate = function(e) {
if (e.candidate)
Log.Debug("received ice candidate", e.candidate);
else
Log.Debug("all candidates received");
}
peer.ondatachannel = function(e) {
Log.Debug("peer connection on data channel", e);
}
this._udpChannel = peer.createDataChannel("webudp", {
ordered: false,
maxRetransmits: 0
});
this._udpChannel.binaryType = "arraybuffer";
this._udpChannel.onerror = function(e) {
Log.Error("data channel error " + e.message);
this._udpTransitFailures+=1;
this._sendUdpDowngrade();
}
let sock = this._sock;
let udpBuffer = this._udpBuffer;
let me = this;
this._udpChannel.onmessage = function(e) {
//Log.Info("got udp msg", e.data);
const u8 = new Uint8Array(e.data);
// Got an UDP packet. Do we need reassembly?
const id = parseInt(u8[0] +
(u8[1] << 8) +
(u8[2] << 16) +
(u8[3] << 24), 10);
const i = parseInt(u8[4] +
(u8[5] << 8) +
(u8[6] << 16) +
(u8[7] << 24), 10);
const pieces = parseInt(u8[8] +
(u8[9] << 8) +
(u8[10] << 16) +
(u8[11] << 24), 10);
const hash = parseInt(u8[12] +
(u8[13] << 8) +
(u8[14] << 16) +
(u8[15] << 24), 10);
// TODO: check the hash. It's the low 32 bits of XXH64, seed 0
const frame_id = parseInt(u8[16] +
(u8[17] << 8) +
(u8[18] << 16) +
(u8[19] << 24), 10);
if (me._transitConnectionState !== me.TransitConnectionStates.Udp) {
me._display.clear();
me._changeTransitConnectionState(me.TransitConnectionStates.Udp);
}
if (pieces == 1) { // Handle it immediately
me._handleUdpRect(u8.slice(20), frame_id);
} else { // Use buffer
const now = Date.now();
if (udpBuffer.has(id)) {
let item = udpBuffer.get(id);
item.recieved_pieces += 1;
item.data[i] = u8.slice(20);
item.total_bytes += item.data[i].length;
if (item.total_pieces == item.recieved_pieces) {
// Message is complete, combile data into a single array
var finaldata = new Uint8Array(item.total_bytes);
let z = 0;
for (let x = 0; x < item.data.length; x++) {
finaldata.set(item.data[x], z);
z += item.data[x].length;
}
udpBuffer.delete(id);
me._handleUdpRect(finaldata, frame_id);
}
} else {
let item = {
total_pieces: pieces, // number of pieces expected
arrival: now, //time first piece was recieved
recieved_pieces: 1, // current number of pieces in data
total_bytes: 0, // total size of all data pieces combined
data: new Array(pieces)
}
item.data[i] = u8.slice(20);
item.total_bytes = item.data[i].length;
udpBuffer.set(id, item);
}
}
}
}
if (this._useUdp) {
setTimeout(function() { this._sendUdpUpgrade() }.bind(this), 3000);
}
Log.Debug("<< RFB.connect");
}
_disconnect() {
Log.Debug(">> RFB.disconnect");
this._cursor.detach();
this._canvas.removeEventListener("gesturestart", this._eventHandlers.handleGesture);
this._canvas.removeEventListener("gesturemove", this._eventHandlers.handleGesture);
this._canvas.removeEventListener("gestureend", this._eventHandlers.handleGesture);
this._canvas.removeEventListener("wheel", this._eventHandlers.handleWheel);
this._canvas.removeEventListener('mousedown', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('mouseup', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('click', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse);
if (document.onpointerlockchange !== undefined) {
document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError);
} else if (document.onmozpointerlockchange !== undefined) {
document.removeEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError);
}
this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
window.removeEventListener('resize', this._eventHandlers.windowResize);
this._keyboard.ungrab();
this._gestures.detach();
this._sock.close();
try {
this._target.removeChild(this._screen);
} catch (e) {
if (e.name === 'NotFoundError') {
// Some cases where the initial connection fails
// can disconnect before the _screen is created
} else {
throw e;
}
}
this._display.dispose();
clearTimeout(this._resizeTimeout);
clearTimeout(this._mouseMoveTimer);
Log.Debug("<< RFB.disconnect");
}
_updateHiddenKeyboard(event) {
// On iOS 15 the navigation bar is at the bottom so we need to account for it
const y = Math.max(0, event.pageY - 50);
document.querySelector("#noVNC_keyboardinput").style.top = `${y}px`;
}
_focusCanvas(event) {
// Hack:
// On most mobile phones it's possible to play audio
// only if it's triggered by user action. It's also
// impossible to listen for touch events on child frames (on mobile phones)
// so we catch those events here but forward the audio unlocking to the parent window
window.parent.postMessage({
action: "enable_audio",
value: null
}, "*");
// Re-enable pointerLock if relative cursor is enabled
// pointerLock must come from user initiated event
if (!this._pointerLock && this._pointerRelativeEnabled) {
this.pointerLock = true;
}
if (!this.focusOnClick) {
return;
}
this.focus();
}
_setDesktopName(name) {
this._fbName = name;
this.dispatchEvent(new CustomEvent(
"desktopname",
{ detail: { name: this._fbName } }));
}
_windowResize(event) {
// If the window resized then our screen element might have
// as well. Update the viewport dimensions.
window.requestAnimationFrame(() => {
this._updateClip();
this._updateScale();
});
if (this._resizeSession) {
// Request changing the resolution of the remote display to
// the size of the local browser viewport.
// In order to not send multiple requests before the browser-resize
// is finished we wait 0.5 seconds before sending the request.
clearTimeout(this._resizeTimeout);
this._resizeTimeout = setTimeout(this._requestRemoteResize.bind(this), 500);
}
}
// Update state of clipping in Display object, and make sure the
// configured viewport matches the current screen size
_updateClip() {
const curClip = this._display.clipViewport;
let newClip = this._clipViewport;
if (this._scaleViewport) {
// Disable viewport clipping if we are scaling
newClip = false;
}
if (curClip !== newClip) {
this._display.clipViewport = newClip;
}
if (newClip) {
// When clipping is enabled, the screen is limited to
// the size of the container.
const size = this._screenSize();
this._display.viewportChangeSize(size.w, size.h);
this._fixScrollbars();
}
}
_updateScale() {
if (!this._scaleViewport) {
this._display.scale = 1.0;
} else {
const size = this._screenSize(false);
this._display.autoscale(size.w, size.h, size.scale);
}
this._fixScrollbars();
}
// Requests a change of remote desktop size. This message is an extension
// and may only be sent if we have received an ExtendedDesktopSize message
_requestRemoteResize() {
clearTimeout(this._resizeTimeout);
this._resizeTimeout = null;
if (!this._resizeSession || this._viewOnly ||
!this._supportsSetDesktopSize) {
return;
}
const size = this._screenSize();
RFB.messages.setDesktopSize(this._sock,
Math.floor(size.w), Math.floor(size.h),
this._screenID, this._screenFlags);
Log.Debug('Requested new desktop size: ' +
size.w + 'x' + size.h);
}
// Gets the the size of the available screen
_screenSize (limited) {
if (limited === undefined) {
limited = true;
}
var x = this._screen.offsetWidth;
var y = this._screen.offsetHeight;
var scale = 0; // 0=auto
try {
if (x > 1280 && limited && this.videoQuality == 1) {
var ratio = y / x;
Log.Debug(ratio);
x = 1280;
y = x * ratio;
}
else if (limited && this.videoQuality == 0){
x = 1280;
y = 720;
} else if (this._display.antiAliasing === 0 && window.devicePixelRatio > 1 && x < 1000 && x > 0) {
// small device with high resolution, browser is essentially zooming greater than 200%
Log.Info('Device Pixel ratio: ' + window.devicePixelRatio + ' Reported Resolution: ' + x + 'x' + y);
let targetDevicePixelRatio = 1.5;
if (window.devicePixelRatio > 2) { targetDevicePixelRatio = 2; }
let scaledWidth = (x * window.devicePixelRatio) * (1 / targetDevicePixelRatio);
let scaleRatio = scaledWidth / x;
x = x * scaleRatio;
y = y * scaleRatio;
scale = 1 / scaleRatio;
Log.Info('Small device with hDPI screen detected, auto scaling at ' + scaleRatio + ' to ' + x + 'x' + y);
}
} catch (err) {
Log.Debug(err);
}
return { w: x,
h: y,
scale: scale };
}
_fixScrollbars() {
// This is a hack because Chrome screws up the calculation
// for when scrollbars are needed. So to fix it we temporarily
// toggle them off and on.
const orig = this._screen.style.overflow;
this._screen.style.overflow = 'hidden';
// Force Chrome to recalculate the layout by asking for
// an element's dimensions
this._screen.getBoundingClientRect();
this._screen.style.overflow = orig;
}
/*
* Connection states:
* connecting
* connected
* disconnecting
* disconnected - permanent state
*/
_updateConnectionState(state) {
const oldstate = this._rfbConnectionState;
if (state === oldstate) {
Log.Debug("Already in state '" + state + "', ignoring");
return;
}
// The 'disconnected' state is permanent for each RFB object
if (oldstate === 'disconnected') {
Log.Error("Tried changing state of a disconnected RFB object");
return;
}
// Ensure proper transitions before doing anything
switch (state) {
case 'connected':
if (oldstate !== 'connecting') {
Log.Error("