UNPKG

novnc

Version:
1,394 lines (1,188 loc) 151 kB
/* * 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("