/* * Copyright (c) 2013 Antoine Martin * Copyright (c) 2016 David Brushinski * Copyright (c) 2014 Joshua Higgins * Copyright (c) 2015 Spikes, Inc. * Licensed under MPL 2.0 * * xpra client * * requires: * Protocol.js * Window.js * Keycodes.js */ const WORKER = Boolean(window.Worker); const CLIPBOARD_IMAGES = true; const CLIPBOARD_EVENT_DELAY = 100; const DECODE_WORKER = WORKER && !!window.createImageBitmap; const SHOW_START_MENU = true; const FILE_SIZE_LIMIT = 4 * 1024 * 1024 * 1024; //are we even allowed to allocate this much memory? const FILE_CHUNKS_SIZE = 128 * 1024; const MAX_CONCURRENT_FILES = 5; const CHUNK_TIMEOUT = 10 * 1000; const MODAL_FOCUS = true; const TEXT_PLAIN = "text/plain"; const UTF8_STRING = "UTF8_STRING"; const TEXT_HTML = "text/html"; const FLOAT_MENU_SELECTOR = "#float_menu"; const PASTEBOARD_SELECTOR = "#pasteboard"; const WINDOW_PREVIEW_SELECTOR = "#window_preview"; const BELL_SOUND = "data:audio/wav;base64,//uQRAAAAWMSLwUIYAAsYkXgoQwAEaYLWfkWgAI0wWs/ItAAAGDgYtAgAyN+QWaAAihwMWm4G8QQRDiMcCBcH3Cc+CDv/7xA4Tvh9Rz/y8QADBwMWgQAZG/ILNAARQ4GLTcDeIIIhxGOBAuD7hOfBB3/94gcJ3w+o5/5eIAIAAAVwWgQAVQ2ORaIQwEMAJiDg95G4nQL7mQVWI6GwRcfsZAcsKkJvxgxEjzFUgfHoSQ9Qq7KNwqHwuB13MA4a1q/DmBrHgPcmjiGoh//EwC5nGPEmS4RcfkVKOhJf+WOgoxJclFz3kgn//dBA+ya1GhurNn8zb//9NNutNuhz31f////9vt///z+IdAEAAAK4LQIAKobHItEIYCGAExBwe8jcToF9zIKrEdDYIuP2MgOWFSE34wYiR5iqQPj0JIeoVdlG4VD4XA67mAcNa1fhzA1jwHuTRxDUQ//iYBczjHiTJcIuPyKlHQkv/LHQUYkuSi57yQT//uggfZNajQ3Vmz+Zt//+mm3Wm3Q576v////+32///5/EOgAAADVghQAAAAA//uQZAUAB1WI0PZugAAAAAoQwAAAEk3nRd2qAAAAACiDgAAAAAAABCqEEQRLCgwpBGMlJkIz8jKhGvj4k6jzRnqasNKIeoh5gI7BJaC1A1AoNBjJgbyApVS4IDlZgDU5WUAxEKDNmmALHzZp0Fkz1FMTmGFl1FMEyodIavcCAUHDWrKAIA4aa2oCgILEBupZgHvAhEBcZ6joQBxS76AgccrFlczBvKLC0QI2cBoCFvfTDAo7eoOQInqDPBtvrDEZBNYN5xwNwxQRfw8ZQ5wQVLvO8OYU+mHvFLlDh05Mdg7BT6YrRPpCBznMB2r//xKJjyyOh+cImr2/4doscwD6neZjuZR4AgAABYAAAABy1xcdQtxYBYYZdifkUDgzzXaXn98Z0oi9ILU5mBjFANmRwlVJ3/6jYDAmxaiDG3/6xjQQCCKkRb/6kg/wW+kSJ5//rLobkLSiKmqP/0ikJuDaSaSf/6JiLYLEYnW/+kXg1WRVJL/9EmQ1YZIsv/6Qzwy5qk7/+tEU0nkls3/zIUMPKNX/6yZLf+kFgAfgGyLFAUwY//uQZAUABcd5UiNPVXAAAApAAAAAE0VZQKw9ISAAACgAAAAAVQIygIElVrFkBS+Jhi+EAuu+lKAkYUEIsmEAEoMeDmCETMvfSHTGkF5RWH7kz/ESHWPAq/kcCRhqBtMdokPdM7vil7RG98A2sc7zO6ZvTdM7pmOUAZTnJW+NXxqmd41dqJ6mLTXxrPpnV8avaIf5SvL7pndPvPpndJR9Kuu8fePvuiuhorgWjp7Mf/PRjxcFCPDkW31srioCExivv9lcwKEaHsf/7ow2Fl1T/9RkXgEhYElAoCLFtMArxwivDJJ+bR1HTKJdlEoTELCIqgEwVGSQ+hIm0NbK8WXcTEI0UPoa2NbG4y2K00JEWbZavJXkYaqo9CRHS55FcZTjKEk3NKoCYUnSQ0rWxrZbFKbKIhOKPZe1cJKzZSaQrIyULHDZmV5K4xySsDRKWOruanGtjLJXFEmwaIbDLX0hIPBUQPVFVkQkDoUNfSoDgQGKPekoxeGzA4DUvnn4bxzcZrtJyipKfPNy5w+9lnXwgqsiyHNeSVpemw4bWb9psYeq//uQZBoABQt4yMVxYAIAAAkQoAAAHvYpL5m6AAgAACXDAAAAD59jblTirQe9upFsmZbpMudy7Lz1X1DYsxOOSWpfPqNX2WqktK0DMvuGwlbNj44TleLPQ+Gsfb+GOWOKJoIrWb3cIMeeON6lz2umTqMXV8Mj30yWPpjoSa9ujK8SyeJP5y5mOW1D6hvLepeveEAEDo0mgCRClOEgANv3B9a6fikgUSu/DmAMATrGx7nng5p5iimPNZsfQLYB2sDLIkzRKZOHGAaUyDcpFBSLG9MCQALgAIgQs2YunOszLSAyQYPVC2YdGGeHD2dTdJk1pAHGAWDjnkcLKFymS3RQZTInzySoBwMG0QueC3gMsCEYxUqlrcxK6k1LQQcsmyYeQPdC2YfuGPASCBkcVMQQqpVJshui1tkXQJQV0OXGAZMXSOEEBRirXbVRQW7ugq7IM7rPWSZyDlM3IuNEkxzCOJ0ny2ThNkyRai1b6ev//3dzNGzNb//4uAvHT5sURcZCFcuKLhOFs8mLAAEAt4UWAAIABAAAAAB4qbHo0tIjVkUU//uQZAwABfSFz3ZqQAAAAAngwAAAE1HjMp2qAAAAACZDgAAAD5UkTE1UgZEUExqYynN1qZvqIOREEFmBcJQkwdxiFtw0qEOkGYfRDifBui9MQg4QAHAqWtAWHoCxu1Yf4VfWLPIM2mHDFsbQEVGwyqQoQcwnfHeIkNt9YnkiaS1oizycqJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU="; const SAFE_ENCODINGS = [ "jpeg", "png", "png/P", "png/L", "rgb", "rgb32", "rgb24", "scroll", "void", ]; const METADATA_SUPPORTED = [ "fullscreen", "maximized", "iconic", "above", "below", "title", "size-hints", "class-instance", "transient-for", "window-type", "has-alpha", "decorations", "override-redirect", "tray", "modal", "opacity", ] // This option adds the CSS class .gpu-trigger to the windows. // The article at https://www.urbaninsight.com/article/improving-html5-app-performance-gpu-accelerated-css-transitions // states that adding 'transform: transale3d(0,0,0);' is the strongest CSS indication for the browser to enable hardware acceleration. const TRY_GPU_TRIGGER = true; function truncate(input) { if (!input) { return input; } const s = input.toString(); if (s.length > 5) { return s.slice(0, 5) + "..."; // eslint-disable-line prefer-template } return s; } class XpraClient { constructor(container) { // the container div is the "screen" on the HTML page where we // are able to draw our windows in. this.container = document.querySelector(`#${container}`); if (!this.container) { throw new Error("invalid container element"); } // assign callback for window resize event if (window.jQuery) { jQuery(window).resize( jQuery.debounce(250, (e) => this._screen_resized(e)) ); } this.protocol = null; this.init_settings(); this.init_state(); } init_settings() { //server: this.host = null; this.port = null; this.ssl = null; this.webtransport = false; this.path = ""; this.username = ""; this.passwords = []; this.insecure = false; this.uri = ""; this.packet_encoder = null; //connection options: this.sharing = false; this.open_url = true; this.steal = true; this.remote_logging = true; this.debug_categories = []; this.start_new_session = null; this.clipboard_enabled = false; this.clipboard_poll = false; this.clipboard_preferred_format = TEXT_PLAIN; this.file_transfer = false; this.remote_file_size_limit = 0; this.remote_file_chunks = 0; this.send_chunks_in_progress = new Map(); this.receive_chunks_in_progress = new Map(); this.keyboard_layout = null; this.printing = false; this.key_packets = []; this.clipboard_delayed_event_time = 0; this.scale = 1; this.vrefresh = -1; this.bandwidth_limit = 0; this.reconnect = true; this.reconnect_count = 10; this.reconnect_in_progress = false; this.reconnect_delay = 2000; //wait 2 seconds before retrying this.reconnect_attempt = 0; this.swap_keys = Utilities.isMacOS(); this.HELLO_TIMEOUT = 30_000; this.OPEN_TIMEOUT = 10_000; this.PING_TIMEOUT = 15_000; this.PING_GRACE = 2000; this.PING_FREQUENCY = 5000; this.INFO_FREQUENCY = 1000; this.uuid = Utilities.getHexUUID(); this.offscreen_api = false; this.try_gpu = TRY_GPU_TRIGGER; this.init_encodings(); } init_encodings() { this.encoding = "auto"; //basic set of encodings: //(more may be added after checking via the DecodeWorker) this.supported_encodings = [ "jpeg", "png", "png/P", "png/L", "rgb", "rgb32", "rgb24", "scroll", "void", ]; //extra encodings we enable if validated via the decode worker: //(we also validate jpeg and png as a sanity check) this.check_encodings = [ "jpeg", "png", "png/P", "png/L", "rgb", "rgb32", "rgb24", "scroll", "webp", "void", "avif", ]; // this may be overriden after detecting the offscreen worker: const video_max_size = [1024, 768]; this.encoding_options = { "": this.encoding, "icons": { "max_size": [30, 30], "greedy": true, }, "transparency": true, "rgb_lz4": (lz4 && lz4.decode != "undefined"), "decoder-speed": { "video": 0 }, "color-gamut": Utilities.getColorGamut(), "video_scaling": true, "video_max_size": video_max_size, "full_csc_modes": { "mpeg1": ["YUV420P"], "h264": ["YUV420P"], "mpeg4+mp4": ["YUV420P"], "h264+mp4": ["YUV420P"], "vp8+webm": ["YUV420P"], "webp": ["BGRX", "BGRA"], "jpeg": ["BGRX", "BGRA", "BGR", "RGBX", "RGBA", "RGB", "YUV420P", "YUV422P", "YUV444P"], "vp8": ["YUV420P"], }, "h264": { "score-delta": 80, "YUV420P": { "profile": "baseline", "level": "2.1", "cabac": false, "deblocking-filter": false, "fast-decode": true, }, }, "h264+mp4": { "score-delta": 50, "YUV420P": { "profile": "baseline", "level": "3.0", }, }, //prefer unmuxed VPX "vp8": { "score-delta": 70, }, "mpeg4+mp4": { "score-delta": 40, }, "vp8+webm": { "score-delta": 40, }, }; } init_state() { // state this.connected = false; this.session_name = undefined; this.desktop_width = 0; this.desktop_height = 0; this.desktop_width = this.container.clientWidth; this.desktop_height = this.container.clientHeight; this.server_remote_logging = false; this.server_start_time = -1; this.client_start_time = new Date(); // some client stuff this.capabilities = {}; this.RGB_FORMATS = ["RGBX", "RGBA", "RGB"]; this.disconnect_reason = null; this.password_prompt_fn = null; this.keycloak_prompt_fn = null; // audio this.audio = null; this.audio_enabled = false; this.audio_mediasource_enabled = MediaSourceUtil.getMediaSourceClass() != undefined; this.audio_aurora_enabled = typeof AV !== "undefined" && AV != undefined && AV.Decoder != undefined && AV.Player.fromXpraSource != undefined; this.audio_codecs = {}; this.audio_framework = null; this.audio_aurora_ctx = null; this.audio_codec = null; this.audio_context = new AudioContext(); this.audio_state = ""; this.aurora_codecs = {}; this.mediasource_codecs = {}; // encryption this.encryption = false; this.encryption_key = null; this.cipher_in_caps = null; this.cipher_out_caps = null; // detect locale change: this.browser_language = Utilities.getFirstBrowserLanguage(); this.browser_language_change_embargo_time = 0; this.key_layout = null; this.last_keycode_pressed = 0; this.last_key_packet = []; // mouse this.buttons_pressed = new Set(); this.last_button_event = [-1, false, -1, -1]; this.mousedown_event = null; this.mouseup_event = null; this.last_mouse_x = null; this.last_mouse_y = null; this.wheel_delta_x = 0; this.wheel_delta_y = 0; this.mouse_grabbed = false; this.scroll_reverse_x = false; this.scroll_reverse_y = "auto"; // clipboard this.clipboard_direction = default_settings["clipboard_direction"] || "both"; this.clipboard_datatype = null; this.clipboard_buffer = ""; this.clipboard_server_buffers = {}; this.clipboard_pending = false; this.clipboard_targets = [TEXT_HTML, UTF8_STRING, "TEXT", "STRING", TEXT_PLAIN]; // printing / file-transfer: this.remote_printing = false; this.remote_file_transfer = false; this.remote_open_files = false; // hello this.hello_timer = null; this.open_timer = null; this.info_timer = null; this.info_request_pending = false; this.server_last_info = {}; // ping this.ping_timeout_timer = null; this.ping_grace_timer = null; this.ping_timer = null; this.last_ping_server_time = 0; this.last_ping_local_time = 0; this.last_ping_echoed_time = 0; this.server_ping_latency = 0; this.client_ping_latency = 0; this.server_load = null; this.server_ok = false; //packet handling this.decode_worker = null; this.decode_worker_timeout = 0; // floating menu this.toolbar_position = "top"; this.server_display = ""; this.server_platform = ""; this.server_resize_exact = false; this.server_screen_sizes = []; this.server_is_desktop = false; this.server_is_shadow = false; this.server_readonly = false; this.server_connection_data = false; this.xdg_menu = null; // a list of our windows this.id_to_window = {}; this.ui_events = 0; this.pending_redraw = []; this.draw_pending = 0; // basic window management this.focused_wid = 0; const me = this; const screen_element = jQuery("#screen"); screen_element.mousedown((e) => this.on_mousedown(e)); screen_element.mouseup((e) => this.on_mouseup(e)); document.getElementById("screen").addEventListener("mousemove", (e) => this.on_mousemove(e)); const div = document.querySelector("#screen"); function on_mousescroll(e) { me.on_mousescroll(e); return e.preventDefault(); } if (Utilities.isEventSupported("wheel")) { div.addEventListener("wheel", on_mousescroll, false); } else { this.warn("browser does not support scroll wheel events"); } } send() { this.debug("network", "sending a", arguments[0], "packet"); if (this.protocol) { this.protocol.send.apply(this.protocol, arguments); } } send_log(level, arguments_) { if (this.remote_logging && this.server_remote_logging && this.connected) { try { const sargs = []; for (const argument of arguments_) { sargs.push(unescape(encodeURIComponent(String(argument)))); } this.send([PACKET_TYPES.logging, level, sargs]); } catch { this.cerror("remote logging failed"); for (const index in arguments_) { const argument = arguments_[index]; this.clog(" argument", index, typeof argument, ":", `'${argument}'`); } } } } exc() { //first argument is the exception: const exception = arguments[0]; let arguments_ = [...arguments]; arguments_ = arguments_.splice(1); if (arguments_.length > 0) { this.cerror(arguments_); } if (exception.stack) { try { //logging.ERROR = 40 this.send_log(40, [exception.stack]); } catch { //we tried our best } } } error() { //logging.ERROR = 40 this.send_log(40, arguments); Reflect.apply(this.cerror, this, arguments); } cerror() { Utilities.cerror.apply(Utilities, arguments); } warn() { //logging.WARN = 30 this.send_log(30, arguments); Reflect.apply(this.cwarn, this, arguments); } cwarn() { Utilities.cwarn.apply(Utilities, arguments); } log() { //logging.INFO = 20 this.send_log(20, arguments); Reflect.apply(this.clog, this, arguments); } clog() { Utilities.clog.apply(Utilities, arguments); } debug() { const category = arguments[0]; const arguments_ = [...arguments]; if (this.debug_categories.includes(category)) { if (category !== "network") { //logging.DEBUG = 10 this.send_log(10, arguments_); } Reflect.apply(this.cdebug, this, arguments); } } cdebug() { Utilities.cdebug.apply(Utilities, arguments); } init(ignore_blacklist) { this.on_connection_progress("Initializing", "", 20); this.init_audio(ignore_blacklist); this.init_packet_handlers(); this.init_keyboard(); if (this.scale !== 1) { this.container.style.width = `${100 * this.scale}%`; this.container.style.height = `${100 * this.scale}%`; this.container.style.transform = `scale(${1 / this.scale})`; this.container.style.transformOrigin = "top left"; } } init_packet_handlers() { // the client holds a list of packet handlers this.packet_handlers = { [PACKET_TYPES.control]: this._process_control, [PACKET_TYPES.ack_file_chunk]: this._process_ack_file_chunk, [PACKET_TYPES.bell]: this._process_bell, [PACKET_TYPES.challenge]: this._process_challenge, [PACKET_TYPES.clipboard_request]: this._process_clipboard_request, [PACKET_TYPES.clipboard_token]: this._process_clipboard_token, [PACKET_TYPES.close]: this._process_close, [PACKET_TYPES.configure_override_redirect]: this._process_configure_override_redirect, [PACKET_TYPES.cursor]: this._process_cursor, [PACKET_TYPES.desktop_size]: this._process_desktop_size, [PACKET_TYPES.disconnect]: this._process_disconnect, [PACKET_TYPES.draw]: this._process_draw, [PACKET_TYPES.encodings]: this._process_encodings, [PACKET_TYPES.eos]: this._process_eos, [PACKET_TYPES.error]: this._process_error, [PACKET_TYPES.hello]: this._process_hello, [PACKET_TYPES.info_response]: this._process_info_response, [PACKET_TYPES.initiate_moveresize]: this._process_initiate_moveresize, [PACKET_TYPES.lost_window]: this._process_lost_window, [PACKET_TYPES.new_override_redirect]: this._process_new_override_redirect, [PACKET_TYPES.new_tray]: this._process_new_tray, [PACKET_TYPES.new_window]: this._process_new_window, [PACKET_TYPES.notify_close]: this._process_notify_close, [PACKET_TYPES.notify_show]: this._process_notify_show, [PACKET_TYPES.open]: this._process_open, [PACKET_TYPES.open_url]: this._process_open_url, [PACKET_TYPES.ping]: this._process_ping, [PACKET_TYPES.ping_echo]: this._process_ping_echo, [PACKET_TYPES.pointer_position]: this._process_pointer_position, [PACKET_TYPES.raise_window]: this._process_raise_window, [PACKET_TYPES.send_file]: this._process_send_file, [PACKET_TYPES.send_file_chunk]: this._process_send_file_chunk, [PACKET_TYPES.set_clipboard_enabled]: this._process_set_clipboard_enabled, [PACKET_TYPES.setting_change]: this._process_setting_change, [PACKET_TYPES.sound_data]: this._process_sound_data, [PACKET_TYPES.startup_complete]: this._process_startup_complete, [PACKET_TYPES.window_icon]: this._process_window_icon, [PACKET_TYPES.window_metadata]: this._process_window_metadata, [PACKET_TYPES.window_move_resize]: this._process_window_move_resize, [PACKET_TYPES.window_resized]: this._process_window_resized, }; } on_connection_progress(state, details, progress) { //can be overriden this.clog(state, details); } callback_close(reason) { if (reason === undefined) { reason = "unknown reason"; } this.clog(`connection closed: ${reason}`); } connect() { let details = `${this.host}:${this.port}${this.path}`; if (this.ssl) { details += " with ssl"; } this.schedule_open_timer(); this.on_connection_progress("Connecting to server", details, 30); // open the web socket, started it in a worker if available // check we have enough information for encryption if (this.encryption && !this.encryption_key) { this.disconnect("no key specified for encryption"); return; } this.initialize_workers(); } initialize_workers() { // detect webworker support and degrade gracefully if (window.Worker) { this.clog("we have webworker support"); } else { this.clog("no webworker support at all."); } if (this.webtransport) { this.protocol = new XpraWebTransportProtocol(); } else if (WORKER) { this.protocol = new XpraProtocolWorkerHost(); } else { this.protocol = new XpraProtocol(); } this.open_protocol(); this.decode_worker_timeout = false; if (!DECODE_WORKER) { this.supported_encodings = SAFE_ENCODINGS; this.offscreen_api = false; this.decode_worker = null; return; } let decode_worker; if (this.offscreen_api) { // check that it is actually available: this.offscreen_api = DECODE_WORKER && XpraOffscreenWorker.isAvailable(this.ssl); } if (this.offscreen_api) { this.clog("initializing offscreen decode worker"); decode_worker = new Worker("js/OffscreenDecodeWorker.js"); } else { this.clog("initializing regular decode worker"); decode_worker = new Worker("js/DecodeWorker.js"); } decode_worker.addEventListener( "message", (e) => { const data = e.data; if (data["draw"]) { this.do_process_draw(data["draw"], data["start"]); return; } if (data["error"]) { const message = data["error"]; const packet = data["packet"]; const wid = packet[1]; const width = packet[2]; const height = packet[3]; const coding = packet[6]; const packet_sequence = packet[8]; this.clog("decode error on ", coding, "packet sequence", packet_sequence, ":", message); if (!this.offscreen_api) { this.clog(" pixel data:", packet[7]); } this.do_send_damage_sequence(packet_sequence, wid, width, height, -1, message); return; } switch (data["result"]) { case true: { const formats = [...data["formats"]]; this.clog("we can decode using a worker:", decode_worker); if (this.decode_worker_timeout) { this.clog("but worker initialization took too long!"); decode_worker.postMessage({cmd: "close"}); this.decode_worker = null; } else { if (this.offscreen_api) { this.set_encoding_option('video_max_size', [4096, 4096]); } this.supported_encodings = formats; this.clog("full list of supported encodings:", this.supported_encodings); this.decode_worker = decode_worker; } break; } case false: this.clog(`we can't decode using a worker: ${data["errors"]}`); this.decode_worker = null; break; default: this.clog("client got unknown message from the decode worker"); this.decode_worker = null; } }, false ); this.clog("decode worker will check:", this.check_encodings); decode_worker.postMessage({cmd: "check", encodings: this.check_encodings}); } open_protocol() { // set protocol to deliver packets to our packet router this.protocol.set_packet_handler((packet) => this._route_packet(packet)); // make uri let uri = ""; if (this.webtransport) { uri = "https"; } else { if (this.ssl) { uri = "wss"; } else { uri = "ws"; } } uri += "://"; uri += this.host; if (this.port) { uri += `:${this.port}`; } uri += this.path; // do open this.uri = uri; this.on_connection_progress("Opening WebSocket connection", uri, 50); this.protocol.open(uri); // console.log("open_protocol() done"); } request_refresh(wid) { this.send([PACKET_TYPES.buffer_refresh, wid, 0, 100, { "refresh-now": true, batch: { reset: true }, }, {}, //no client_properties ]); } redraw_windows() { for (const wid in this.id_to_window) { const win = this.id_to_window[wid]; this.request_redraw(win); } } remove_windows() { for (const wid in this.id_to_window) { const win = this.id_to_window[wid]; window.removeWindowListItem(win.wid); win.destroy(); } if (this.decode_worker) { this.decode_worker.postMessage({cmd: "close"}); this.decode_worker = null; } } send_close_window(win) { this.send([PACKET_TYPES.close_window, win.wid]); } close_protocol() { this.connected = false; if (this.protocol) { this.protocol.close(); this.protocol.terminate(); this.protocol = null; } } clear_timers() { this.stop_info_timer(); this.cancel_hello_timer(); if (this.ping_timer) { clearTimeout(this.ping_timer); this.ping_timer = null; } if (this.ping_timeout_timer) { clearTimeout(this.ping_timeout_timer); this.ping_timeout_timer = null; } if (this.ping_grace_timer) { clearTimeout(this.ping_grace_timer); this.ping_grace_timer = null; } } set_encoding(encoding) { // add an encoding to our hello.encodings list this.clog("encoding:", encoding); this.encoding = encoding; } set_encoding_option(option, value) { this.clog("encoding: ", option, "=", value); this.encoding_options[option] = value; } _route_packet(packet) { // ctx refers to `this` because we came through a callback const packet_type = Utilities.s(packet[0]); this.debug("network", "received a", packet_type, "packet"); const function_ = this.packet_handlers[packet_type]; if (function_ === undefined) { this.cerror("no packet handler for ", packet_type); this.clog(packet); } else { function_.call(this, packet); } } _screen_resized(event) { // send the desktop_size packet so server knows we changed size if (!this.connected) { return; } if (this.container.clientWidth === this.desktop_width && this.container.clientHeight === this.desktop_height) { // unchanged return; } this.desktop_width = this.container.clientWidth; this.desktop_height = this.container.clientHeight; const dpi = this._get_DPI(); const packet = [PACKET_TYPES.configure_display, { "desktop-size": [this.desktop_width, this.desktop_height], // "desktop-size-unscaled": [this.desktop_width, this.desktop_height], - we don't do desktop scaling "monitors": this._get_monitors(), "dpi": {"x": dpi, "y": dpi}, "vrefresh": this.vrefresh, }]; this.send(packet); // call the screen_resized function on all open windows for (const index in this.id_to_window) { const win = this.id_to_window[index]; win.screen_resized(); // Force fullscreen on a a given window name from the provided settings if ( default_settings !== undefined && default_settings.auto_fullscreen !== undefined && default_settings.auto_fullscreen.length > 0 ) { const pattern = new RegExp(`.*${default_settings.auto_fullscreen}.*`); if (win.fullscreen === false && pattern.test(win.metadata.title)) { clog(`auto fullscreen window: ${win.metadata.title}`); win.set_fullscreen(true); win.screen_resized(); } } // Make a DESKTOP-type window fullscreen automatically. // This resizes things like xfdesktop according to the window size. if (this.fullscreen === false && this.client.is_window_desktop(win)) { clog(`auto fullscreen desktop window: ${this.metadata.title}`); this.set_fullscreen(true); this.screen_resized(); } } // Re-position floating toolbar menu this.position_float_menu(); } /** * Keyboard */ init_keyboard() { this.query_keyboard_map(); } do_init_keyboard() { // modifier keys: this.num_lock_modifier = null; this.alt_modifier = null; this.control_modifier = "control"; this.meta_modifier = null; this.altgr_modifier = null; this.altgr_state = false; this.capture_keyboard = false; // assign the key callbacks document.addEventListener("keydown", (e) => { const preview_element = $(WINDOW_PREVIEW_SELECTOR); if (e.code === "Escape" && preview_element.is(":visible")) { client.toggle_window_preview(); return e.stopPropagation() || e.preventDefault(); } if (e.code === "Tab") { if (preview_element.is(":visible")) { // Select next for previous window. const number_slides = $(".window-preview-item-container").length; const current_slide = preview_element.slick("slickCurrentSlide"); let next_index = current_slide; next_index = e.shiftKey ? (current_slide - 1) % number_slides : (current_slide + 1) % number_slides; preview_element.slick("goTo", next_index, true); return e.stopPropagation() || e.preventDefault(); } else if (e.altKey) { // Alt+Tab shows window preview. and goes to the next window. client.toggle_window_preview((e, slick) => { const number_slides = slick.slideCount; const current_slide = slick.currentSlide; const next_index = (current_slide + 1) % number_slides; setTimeout(() => { slick.goTo(next_index, true); }, 10); }); return e.stopPropagation() || e.preventDefault(); } } const r = this._keyb_onkeydown(e); if (!r) { e.preventDefault(); } }); document.addEventListener("keyup", (e) => { if ( (e.code === "Tab" || e.code.startsWith("Alt")) && $(WINDOW_PREVIEW_SELECTOR).is(":visible") ) { if (e.code.startsWith("Alt")) { client.toggle_window_preview(); } return e.stopPropagation() || e.preventDefault(); } const r = this._keyb_onkeyup(e); if (!r) { e.preventDefault(); } }); } query_keyboard_map() { const keyboard = navigator.keyboard; this.keyboard_map = {}; if (!navigator.keyboard) { this.do_init_keyboard(); return; } keyboard.getLayoutMap().then((keyboardLayoutMap) => { clog("got a keyboard layout map:", keyboardLayoutMap); clog("keys:", [...keyboardLayoutMap.keys()]); for (const key of keyboardLayoutMap.keys()) { const value = keyboardLayoutMap[key]; cdebug("keyboard", key, "=", value); this.keyboard_map[key] = value; } this.do_init_keyboard(); }, (error) => { this.cwarn("failed to get keyboard layout:", error); this.do_init_keyboard(); } ); if (keyboard.addEventListener) { keyboard.addEventListener("layoutchange", () => this.clog("keyboard layout has changed!") ); } } send_keymap() { const keymap = this._get_keymap_caps(); const props = {"keymap": keymap}; this.send([PACKET_TYPES.keymap_changed, props, false]); } _keyb_get_modifiers(event) { /** * Returns the modifiers set for the current event. * We get the list of modifiers using "get_event_modifiers" * then we translate them. */ //convert generic modifiers "meta" and "alt" into their x11 name: const modifiers = get_event_modifiers(event); return this.translate_modifiers(modifiers); } translate_modifiers(modifiers) { /** * We translate "alt" and "meta" into their keymap name. * (usually "mod1") * And also swap keys for macos clients. */ //convert generic modifiers "meta" and "alt" into their x11 name: const alt = this.alt_modifier; let control = this.control_modifier; let meta = this.meta_modifier; const altgr = this.altgr_modifier; if (this.swap_keys) { meta = this.control_modifier; control = this.meta_modifier; } const new_modifiers = [...modifiers]; let index = modifiers.indexOf("meta"); if (index >= 0 && meta) new_modifiers[index] = meta; index = modifiers.indexOf("control"); if (index >= 0 && control) new_modifiers[index] = control; index = modifiers.indexOf("alt"); if (index >= 0 && alt) new_modifiers[index] = alt; index = modifiers.indexOf("numlock"); if (index >= 0) { if (this.num_lock_modifier) { new_modifiers[index] = this.num_lock_modifier; } else { new_modifiers.splice(index, 1); } } index = modifiers.indexOf("capslock"); if (index >= 0) { new_modifiers[index] = "lock"; } //add altgr? if (this.altgr_state && altgr && !new_modifiers.includes(altgr)) { new_modifiers.push(altgr); //remove spurious modifiers: index = new_modifiers.indexOf(alt); if (index >= 0) new_modifiers.splice(index, 1); index = new_modifiers.indexOf(control); if (index >= 0) new_modifiers.splice(index, 1); } return new_modifiers; } _check_browser_language(key_layout) { /** * This function may send the new detected keyboard layout. * (ignoring the keyboard_layout preference) */ const now = performance.now(); if (now < this.browser_language_change_embargo_time) { return; } let new_layout; if (key_layout) { new_layout = key_layout; } else { //we may have used a different layout for a specific key, //and now this new key doesn't need it anymore, //so we may want to switch back to the original layout: const l = Utilities.getFirstBrowserLanguage(); if (l && this.browser_language !== l) { //if the browser language has changed, //this takes precedence over the configuration this.clog("browser language changed from", this.browser_language, "to", l); this.browser_language = l; new_layout = Utilities.getKeyboardLayout(); } else { //this will honour the setting supplied by the user on the connect page //or default to Utilities.getKeyboardLayout() new_layout = this._get_keyboard_layout() || "us"; } } if (new_layout && this.key_layout !== new_layout) { this.key_layout = new_layout; this.clog("keyboard layout changed from", this.key_layout, "to", key_layout); this.send_keymap(); //changing the language too quickly can cause problems server side, //wait a bit before checking again: this.browser_language_change_embargo_time = now + 1000; } else { //check again after 100ms minimum this.browser_language_change_embargo_time = now + 100; } } _keyb_process(pressed, event) { if (this.server_readonly) { return true; } if (!this.capture_keyboard) { return true; } /** * Process a key event: key pressed or key released. * Figure out the keycode, keyname, modifiers, etc * And send the event to the server. */ let keyname = event.code || ""; const keycode = event.which || event.keyCode; if (keycode === 229) { //this usually fires when we have received the event via "oninput" already return; } let keystring = event.key || String.fromCharCode(keycode); let unpress_now = false; this.debug("keyboard", "last keycode pressed=", this.last_keycode_pressed, ", keycode=", keycode, ", pressed=", pressed, ", str=", keystring); const dead = keystring.toLowerCase() === "dead"; if (dead && ((this.last_keycode_pressed !== keycode && !pressed) || pressed)) { //dead key unpress without first getting a key pressed event, //or just a regular pressed dead key, in both cases send a pair: pressed = true; unpress_now = true; } this.last_keycode_pressed = pressed ? keycode : 0; this.debug("keyboard", "processKeyEvent(", pressed, ", ", event, ") key=", keyname, "keycode=", keycode, "dead=", dead); //sync numlock if (keycode === 144 && pressed) { this.num_lock = !this.num_lock; } let key_language = null; //some special keys are better mapped by name: const map_string = this.keyboard_map[keyname]; if (dead && map_string && map_string in DEAD_KEYS) { keyname = DEAD_KEYS[map_string]; keystring = map_string; this.debug("keyboard", "dead key:", keyname); } else if (keyname in KEY_TO_NAME) { keyname = KEY_TO_NAME[keyname]; } else if (keyname === "" && keystring in KEY_TO_NAME) { keyname = KEY_TO_NAME[keystring]; } //special case for numpad, //try to distinguish arrowpad and numpad: //(for arrowpad, keyname==str) else if (keyname !== keystring && keystring in NUMPAD_TO_NAME) { keyname = NUMPAD_TO_NAME[keystring]; this.num_lock = "0123456789.".includes(keyname); } //next try mapping the actual character else if (keystring in CHAR_TO_NAME) { keyname = CHAR_TO_NAME[keystring]; if (keyname.includes("_")) { //ie: Thai_dochada const lang = keyname.split("_")[0]; key_language = KEYSYM_TO_LAYOUT[lang]; } } //fallback to keycode map: else { if (keycode in CHARCODE_TO_NAME) { keyname = CHARCODE_TO_NAME[keycode]; } //may override with shifted table: if (event.getModifierState && event.getModifierState("Shift") && keycode in CHARCODE_TO_NAME_SHIFTED) { keyname = CHARCODE_TO_NAME_SHIFTED[keycode]; } } this._check_browser_language(key_language); const DOM_KEY_LOCATION_RIGHT = 2; if (keyname.match("_L$") && event.location === DOM_KEY_LOCATION_RIGHT) keyname = keyname.replace("_L", "_R"); //AltGr: keep track of pressed state if ( keystring === "AltGraph" || (keyname === "Alt_R" && (Utilities.isWindows() || Utilities.isMacOS())) || (keyname === "Alt_L" && Utilities.isMacOS()) ) { this.altgr_state = pressed; keyname = "ISO_Level3_Shift"; keystring = "AltGraph"; } const raw_modifiers = get_event_modifiers(event); const modifiers = this._keyb_get_modifiers(event); const keyval = keycode; const group = 0; const shift = modifiers.includes("shift"); const capslock = modifiers.includes("capslock"); if ((capslock && shift) || (!capslock && !shift)) { keystring = keystring.toLowerCase(); } const ostr = keystring; if (this.swap_keys) { if (keyname === "Control_L") { keyname = "Meta_L"; keystring = "meta"; } else if (keyname === "Meta_L") { keyname = "Control_L"; keystring = "control"; } else if (keyname === "Control_R") { keyname = "Meta_R"; keystring = "meta"; } else if (keyname === "Meta_R") { keyname = "Control_R"; keystring = "control"; } } //macos will swallow the key release event if the meta modifier is pressed, //so simulate one immediately: if (pressed && Utilities.isMacOS() && raw_modifiers.includes("meta") && ostr !== "meta") { unpress_now = true; } let allow_default = false; if (this.clipboard_enabled && client.clipboard_direction !== "to-server") { //allow some key events that need to be seen by the browser //for handling the clipboard: let clipboard_modifier_keys = ["Control_L", "Control_R", "Shift_L", "Shift_R"]; let clipboard_modifier = "control"; if (Utilities.isMacOS()) { //Apple does things differently, as usual: clipboard_modifier_keys = ["Meta_L", "Meta_R", "Shift_L", "Shift_R"]; clipboard_modifier = "meta"; } //let the OS see Control (or Meta on macos) and Shift: if (clipboard_modifier_keys.includes(keyname)) { this.debug("keyboard", "passing clipboard modifier key event to browser:", keyname); allow_default = true; } //let the OS see Shift + Insert: if (shift && keyname === "Insert") { this.debug("keyboard", "passing clipboard combination Shift+Insert to browser"); allow_default = true; } const is_clipboard_modifier_set = raw_modifiers.includes(clipboard_modifier); if (is_clipboard_modifier_set) { const l = keyname.toLowerCase(); if (l === "c" || l === "x" || l === "v") { this.debug("keyboard", "passing clipboard combination to browser:", clipboard_modifier, "+", keyname); allow_default = true; if (l === "v") { this.clipboard_delayed_event_time = performance.now() + CLIPBOARD_EVENT_DELAY; } } } } const wid = this.focused_wid; this.debug("keyboard", "focused=", this.focused, "keyname=", keyname); let packet = [PACKET_TYPES.key_action, wid, keyname, pressed, modifiers, keyval, keystring, keycode, group]; this.key_packets.push(packet); if (unpress_now) { packet = [PACKET_TYPES.key_action, wid, keyname, false, modifiers, keyval, keystring, keycode, group]; this.key_packets.push(packet); } //if there is a chance that we're in the process of handling //a clipboard event (a click or control-v) //then we send with a slight delay: let delay = 0; const now = performance.now(); if (this.clipboard_delayed_event_time > now) { delay = this.clipboard_delayed_event_time - now; } const me = this; setTimeout(() => { while (this.key_packets.length > 0) { const key_packet = me.key_packets.shift(); this.last_key_packet = key_packet; this.send(key_packet); } }, delay); if (keyname === "F11") { this.debug("keyboard", "allowing default handler for", keyname); allow_default = true; } return allow_default; } _keyb_onkeydown(event) { return this._keyb_process(true, event); } _keyb_onkeyup(event) { return this._keyb_process(false, event); } _get_keyboard_layout() { this.debug("keyboard", "_get_keyboard_layout() keyboard_layout=", this.keyboard_layout); if (this.keyboard_layout) return this.keyboard_layout; return Utilities.getKeyboardLayout(); } _get_keycodes() { //keycodes.append((nn(keyval), nn(name), nn(keycode), nn(group), nn(level))) const keycodes = []; let kc; for (const keycode in CHARCODE_TO_NAME) { kc = Number.parseInt(keycode); keycodes.push([kc, CHARCODE_TO_NAME[keycode], kc, 0, 0]); } return keycodes; } _get_desktop_size() { return [this.desktop_width, this.desktop_height]; } _get_DPI() { const dpi_div = document.querySelector("#dpi"); if (dpi_div && dpi_div.offsetWidth > 0 && dpi_div.offsetHeight > 0) { return Math.round((dpi_div.offsetWidth + dpi_div.offsetHeight) / 2); } //alternative: if ("deviceXDPI" in screen) { return (screen.systemXDPI + screen.systemYDPI) / 2; } //default: return 96; } _get_screen_sizes() { const dpi = this._get_DPI(); const screen_size = [this.container.clientWidth, this.container.clientHeight]; const wmm = Math.round((screen_size[0] * 25.4) / dpi); const hmm = Math.round((screen_size[1] * 25.4) / dpi); const monitor = ["Canvas", 0, 0, screen_size[0], screen_size[1], wmm, hmm]; let name = "HTML"; if (navigator.userAgentData) { const brands = navigator.userAgentData.brands; for (let index = 0; index < brands.length; index++) { const brand_info = brands[index]; const brand = brand_info.brand; if (brand && !brand.startsWith("Not") && !brand.endsWith("Brand")) { name = brand + " " + brand_info.version; } } } const screen = [ name, screen_size[0], screen_size[1], wmm, hmm, [monitor], 0, 0, screen_size[0], screen_size[1], ]; //just a single screen: return [screen]; } _update_capabilities(appendobj) { for (const attribute in appendobj) { this.capabilities[attribute] = appendobj[attribute]; } } /** * Ping */ _check_server_echo(ping_sent_time) { const last = this.server_ok; this.server_ok = this.last_ping_echoed_time >= ping_sent_time; if (last !== this.server_ok) { if (!this.server_ok) { this.clog("server connection is not responding, drawing spinners..."); } else { this.clog("server connection is OK"); } for (const index in this.id_to_window) { const win = this.id_to_window[index]; win.set_spinner(this.server_ok); } } } _check_echo_timeout(ping_time) { if (this.reconnect_in_progress) { return; } if (this.last_ping_echoed_time > 0 && this.last_ping_echoed_time < ping_time) { if (this.reconnect && this.reconnect_attempt < this.reconnect_count) { this.warn("ping timeout - reconnecting"); this.reconnect_attempt++; this.do_reconnect(); } else { // no point in telling the server here... this.disconnect(`server ping timeout, waited ${this.PING_TIMEOUT}ms without a response`); } } } _emit_event(event_type) { const event = document.createEvent("Event"); event.initEvent(event_type, true, true); document.dispatchEvent(event); } emit_connection_lost(event_type) { this._emit_event("connection-lost"); } emit_connection_established(event_type) { this._emit_event("connection-established"); } /** * Hello */ _send_hello(counter) { counter = counter || 0; this.decode_worker_timeout = counter >= 100; if (!DECODE_WORKER || this.decode_worker || this.decode_worker_timeout) { // we don't need to wait for the decode worker (ie: disabled), // or we have successfully initialized it, // or we have already waited too long... this.do_send_hello(null, null); return; } if (counter === 0) { this.on_connection_progress("Waiting for decode worker", "", 70); } setTimeout(() => this._send_hello(counter + 1), 100); } do_send_hello(challenge_response, client_salt) { this.on_connection_progress("Sending handshake", "", 90); // make the base hello this._make_hello_base(); // handle a challenge if we need to if (this.passwords.length > 0 && !challenge_response) { // tell the server we expect a challenge (this is a partial hello) this.capabilities["challenge"] = true; this.clog("sending partial hello"); } else { this.clog("sending hello"); // finish the hello this._make_hello(); } if (challenge_response) { this._update_capabilities({ "challenge_response": challenge_response }); if (client_salt) { this._update_capabilities({ "challenge_client_salt": client_salt }); } } this.clog("sending hello capabilities", this.capabilities); // verify: for (const key in this.capabilities) { if (key === undefined || key === null) { throw new Error("invalid null or undefined key in hello packet data"); } const value = this.capabilities[key]; if (value === undefined || value === null) { throw new Error(`invalid null or undefined value for key ${key} in hello packet data`); } } // send the packet this.send([PACKET_TYPES.hello, this.capabilities]); this.schedule_hello_timer(); } _make_hello_base() { this.capabilities = {}; this._update_capabilities({ "version": Utilities.VERSION, "client_type": "HTML5", "display": this._get_display_caps(), "build": this._get_build_caps(), "platform": this._get_platform_caps(), "session-type": Utilities.getSimpleUserAgentString(), "session-type.full": navigator.userAgent, "username": this.username, "uuid": this.uuid, "argv": [window.location.href], "share": this.sharing, "steal": this.steal, "mouse.show": true, "vrefresh": this.vrefresh, "file-chunks": FILE_CHUNKS_SIZE, "setting-change": true, // Required by v5 servers "xdg-menu-update": true, "xdg-menu": true, "control_commands": [ "log", "redraw", "stop-audio", "toggle-keyboard", "toggle-float-menu", "toggle-window-preview", ], }); this._update_capabilities(this._get_network_caps()); if (this.encryption) { this.cipher_in_caps = this._get_cipher_caps() this._update_capabilities({ "encryption": this.cipher_in_caps }); console.info("setting cipher in caps=", JSON.stringify(this.cipher_in_caps)); this.protocol.set_cipher_in(this.cipher_in_caps, this.encryption_key); } if (this.start_new_session) { this._update_capabilities({ "start-new-session": this.start_new_session, }); } } _make_hello() { this.desktop_width = this.container.clientWidth; this.desktop_height = this.container.clientHeight; this.key_layout = this._get_keyboard_layout(); this._update_capabilities({ auto_refresh_delay: 500, "metadata.supported": METADATA_SUPPORTED, "encodings": { "": this.supported_encodings, "core": this.supported_encodings, "rgb_formats": this.RGB_FORMATS, "window-icon": ["png"], "cursor": ["png"], "packet": true, }, "encoding": this._get_encoding_caps(), "audio": this._get_audio_caps(), "clipboard": this._get_clipboard_caps(), "pointer": this._get_pointer_caps(), "keymap": this._get_keymap_caps(), "file": this._get_file_caps(), "wants": ["audio", ], // encoding stuff windows: true, "window.pre-map": true, //partial support: keyboard: true, screen_sizes: this._get_screen_sizes(), dpi: { "x": this._get_DPI(), "y": this._get_DPI(), }, notifications: { "enabled": true, }, cursors: true, bell: true, system_tray: true, //we cannot handle this (GTK only): named_cursors: false, }); } _get_display_caps() { return { "refresh-rate": this.vrefresh, "desktop_size": [this.desktop_width, this.desktop_height], "desktop_mode_size": [this.desktop_width, this.desktop_height], "screen_sizes": this._get_screen_sizes(), "monitors": this._get_monitors(), } } _get_monitors() { const monitors = new Map(); monitors.set(0, this._get_monitor()); return monitors; } _get_monitor() { const dpi = this._get_DPI(); const wmm = Math.round((this.desktop_width * 25.4) / dpi); const hmm = Math.round((this.desktop_height * 25.4) / dpi); return { "geometry": [0, 0, this.desktop_width, this.desktop_height], "primary": true, "refresh-rate": this.vrefresh, // "scale-factor": 1, "width-mm": wmm, "height-mm": hmm, "manufacturer": Utilities.getOS(), "model": Utilities.getBrowserName(), // "subpixel-layout": "BGR", - not available in browsers "workarea": [0, 0, this.desktop_width, this.desktop_height], "name": "Canvas", } } _get_file_caps() { return { "enabled": true, "printing": this.printing, "open-url": this.open_url, "size-limit": 32 * 1024 * 1024, } } _get_network_caps() { const digests = this._get_digests(); return { "digest": digests, "salt-digest": digests, "compression_level": 1, "rencodeplus": true, "brotli": (typeof BrotliDecode === "function"), "lz4": Boolean(lz4 && lz4.decode), "bandwidth-limit": this.bandwidth_limit, "connection-data": Utilities.getConnectionInfo(), "network": { "pings": 5, } } } _get_digests() { const digests = ["xor", "keycloak", "hmac+sha256"]; if (typeof crypto.subtle !== "undefined") { try { this.debug("network", "crypto.subtle=", crypto.subtle); for (const hash of ["sha1", "sha256", "sha384", "sha512"]) { digests.push("hmac+" + hash); } this.debug("network", "digests:", digests); } catch { this.cerror("Error probing crypto.subtle digests"); } } else { this.clog("cryptography library 'crypto.subtle' not found"); } return digests; } _get_cipher_caps() { const enc = this.encryption.split("-")[0]; if (enc !== "AES") { throw `invalid encryption specified: '${enc}'`; } const mode = this.encryption.split("-")[1] || "CBC"; return { "cipher": enc, "mode": mode, "iv": Utilities.getSecureRandomString(16), "key_salt": Utilities.getSecureRandomBytes(64), "key_size": 32, //256 bits "key_hash": "SHA1", "key_stretch_iterations": 1000, "padding.options": ["PKCS#7"], "always-pad": true, "stream": false, } } _get_build_caps() { return { "revision": Utilities.REVISION, "local_modifications": Utilities.LOCAL_MODIFICATIONS, "branch": Utilities.BRANCH, } } _get_platform_caps() { return { "": Utilities.getPlatformName(), "name": Utilities.getPlatformName(), "processor": Utilities.getPlatformProcessor(), "platform": navigator.appVersion, } } _get_audio_caps() { return { "receive": true, "send": true, "decoders": Object.keys(this.audio_codecs), } } _get_keymap_caps() { return { "layout": this.key_layout, "keycodes": this._get_keycodes(), } } _get_pointer_caps() { return { "double_click": {}, } } _get_clipboard_caps() { let selections; if (navigator.clipboard && navigator.clipboard.readText && navigator.clipboard.writeText) { //we don't need the primary contents, //we can use the async clipboard selections = ["CLIPBOARD"]; this.log("using new navigator.clipboard"); } else { selections = ["CLIPBOARD", "PRIMARY"]; this.log("legacy clipboard"); } this.log("clipboard polling: ", this.clipboard_poll); this.clipboard_targets = [this.clipboard_preferred_format]; for (const target of [TEXT_HTML, UTF8_STRING, "TEXT", "STRING", TEXT_PLAIN]) { if (target !== this.clipboard_preferred_format) { this.clipboard_targets.push(target); } } if (CLIPBOARD_IMAGES && navigator.clipboard && Object.hasOwn(navigator.clipboard, "write")) { this.clipboard_targets.push("image/png"); } else { this.log("no clipboard write support: no images, navigator.clipboard=", navigator.clipboard); } this.log("clipboard targets: ", this.clipboard_targets); this.log("clipboard preferred format: ", this.clipboard_preferred_format); return { "enabled": this.clipboard_enabled, "want_targets": true, "greedy": true, "selections": selections, "preferred-targets": this.clipboard_targets, } } _get_encoding_caps() { return this.encoding_options; } on_first_ui_event() { //this hook can be overriden } _new_ui_event() { if (this.ui_events === 0) { this.on_first_ui_event(); } this.ui_events++; } /** * Mouse handlers */ getMouse(e) { const windowIsLocked = Boolean(document.pointerLockElement); // get mouse position take into account scroll let mx = e.clientX + jQuery(document).scrollLeft(); let my = e.clientY + jQuery(document).scrollTop(); if (windowIsLocked) { mx = e.movementX; my = e.movementY; } if (this.scale !== 1) { mx = Math.round(mx * this.scale); my = Math.round(my * this.scale); } // check last mouse position incase the event // hasn't provided it - bug #854 if (isNaN(mx) || isNaN(my)) { if (!isNaN(this.last_mouse_x) && !isNaN(this.last_mouse_y)) { mx = this.last_mouse_x; my = this.last_mouse_y; } else { // should we avoid sending NaN to the server? mx = 0; my = 0; } } else { if (windowIsLocked) { this.last_mouse_x += mx; this.last_mouse_y += my; } this.last_mouse_x = mx; this.last_mouse_y = my; } let mbutton = 0; if ("which" in e) // Gecko (Firefox), WebKit (Safari/Chrome) & Opera mbutton = Math.max(0, e.which); else if ("button" in e) // IE, Opera (zero based) mbutton = Math.max(0, e.button) + 1; mx = this.last_mouse_x; my = this.last_mouse_y; // We return a simple javascript object (a hash) with x and y defined return { x: mx, y: my, button: mbutton }; } on_mousedown(e, win) { this.mousedown_event = e; this.mouseup_event = null; this.do_window_mouse_click(e, win, true); return !win; } on_mouseup(e, win) { this.mouseup_event = e; this.do_window_mouse_click(e, win, false); return !win; } on_mousemove(e, win) { if (this.mouse_grabbed) { return true; } // Ignore events when server is readonly, disconnected or if the event is not over the screen while in shadow mode if (this.server_readonly || !this.connected || (!win && this.server_is_shadow)) { return !win; } const mouse = this.getMouse(e); const x = Math.round(mouse.x); const y = Math.round(mouse.y); const modifiers = this._keyb_get_modifiers(e); const buttons = []; const coords = [x, y]; let wid = 0; if (this.server_is_desktop) { wid = 1; } if (win) { wid = win.wid; // add relative coordinates: const pos = win.get_internal_geometry(); coords.push(Math.round(mouse.x - pos.x)); coords.push(Math.round(mouse.y - pos.y)); e.preventDefault(); } this.send([PACKET_TYPES.pointer_position, wid, coords, modifiers, buttons]); return !win; } release_buttons(e, win) { const mouse = this.getMouse(e); const x = Math.round(mouse.x); const y = Math.round(mouse.y); const modifiers = this._keyb_get_modifiers(e); const pressed = false; const coords = [x, y]; let wid = 0; if (win) { wid = win.wid; // add relative coordinates: const pos = win.get_internal_geometry(); coords.push(Math.round(mouse.x - pos.x)); coords.push(Math.round(mouse.y - pos.y)); } for (const button of this.buttons_pressed) { this.send_button_action(wid, button, pressed, coords, modifiers); } } do_window_mouse_click(e, win, pressed) { if (win) { e.preventDefault(); } // Ignore events when server is readonly, disconnected or if the event is not over the screen while in shadow mode if (this.server_readonly || this.mouse_grabbed || !this.connected || (!win && this.server_is_shadow)) { return; } // Skip processing if clicked on float menu if ( $(e.target).attr("id") === FLOAT_MENU_SELECTOR.slice(1) || $(e.target).parents(FLOAT_MENU_SELECTOR).length > 0 ) { this.debug("clicked on float_menu, skipping event handler", e); return; } let send_delay = 0; const client = this; if (client.clipboard_direction !== "to-server" && this._poll_clipboard(e)) { send_delay = CLIPBOARD_EVENT_DELAY; } const mouse = this.getMouse(e, win); const x = Math.round(mouse.x); const y = Math.round(mouse.y); const modifiers = this._keyb_get_modifiers(e); const coords = [x, y]; let wid = 0; if (win) { wid = win.wid; // add relative coordinates: const pos = win.get_internal_geometry(); coords.push(Math.round(mouse.x - pos.x)); coords.push(Math.round(mouse.y - pos.y)); } // dont call set focus unless the focus has actually changed if (wid > 0 && this.focused_wid !== wid) { this.set_focus(win); } if (window.cursor_lock && win.canvas) { $("#cursor-lock-button").removeClass("icon-paused"); win.canvas.requestPointerLock(); } let button = mouse.button; const lbe = this.last_button_event; if (lbe[0] === button && lbe[1] === pressed && lbe[2] === x && lbe[3] === y) { //duplicate! this.debug("mouse", "skipping duplicate click event"); return; } this.last_button_event = [button, pressed, x, y]; this.debug("mouse", "click:", button, pressed, x, y); if (button === 4) { button = 8; } else if (button === 5) { button = 9; } function send_button_action() { client.clipboard_delayed_event_time = performance.now() + CLIPBOARD_EVENT_DELAY; client.send_button_action(wid, button, pressed, coords, modifiers); } if (send_delay) { setTimeout(send_button_action, send_delay) } else { send_button_action(); } } send_button_action(wid, button, pressed, coords, modifiers) { const buttons = []; if (pressed) { this.buttons_pressed.add(button); } else { this.buttons_pressed.delete(button); } this.send([PACKET_TYPES.button_action, wid, button, pressed, coords, modifiers, buttons]); } // Source: https://deepmikoto.com/coding/1--javascript-detect-mouse-wheel-direction detect_vertical_scroll_direction(e) { if (!e) { //IE? In any case, detection won't work: return 0; } let delta = 0; if (e.wheelDelta) { // will work in most cases delta = e.wheelDelta; } else if (e.detail) { // fallback for Firefox delta = -e.detail; } if (!delta) { return 0; } if (delta > 0) { return -1; } if (delta < 0) { return 1; } return 0; } on_mousescroll(e, win) { // Ignore events when server is readonly, disconnected or if the event is not over the screen while in shadow mode if (this.server_readonly || this.mouse_grabbed || !this.connected || (!win && this.server_is_shadow)) { return false; } const mouse = this.getMouse(e); const x = Math.round(mouse.x); const y = Math.round(mouse.y); const modifiers = this._keyb_get_modifiers(e); const buttons = []; let wid = 0; const coords = [x, y]; if (win) { wid = win.wid; // add relative coordinates: const pos = win.get_internal_geometry(); coords.push(Math.round(mouse.x - pos.x)); coords.push(Math.round(mouse.y - pos.y)); } const wheel = Utilities.normalizeWheel(e); this.debug("mouse", "normalized wheel event:", wheel); //clamp to prevent event floods: let px = Math.min(1200, wheel.pixelX); let py = Math.min(1200, wheel.pixelY); if (this.scroll_reverse_x) { px = -px; } if (this.scroll_reverse_y == true || (this.scroll_reverse_x == "auto" && this.detect_vertical_scroll_direction(e) < 0 && py > 0)) { py = -py; } const apx = Math.abs(px); const apy = Math.abs(py); if (this.server_precise_wheel) { if (apx > 0) { const button_x = px >= 0 ? 7 : 6; const xdist = Math.round((px * 1000) / 120); this.send([PACKET_TYPES.wheel_motion, wid, button_x, -xdist, coords, modifiers, buttons]); } if (apy > 0) { const button_y = py >= 0 ? 5 : 4; const ydist = Math.round((py * 1000) / 120); this.send([PACKET_TYPES.wheel_motion, wid, button_y, -ydist, coords, modifiers, buttons]); } return; } //generate a single event if we can, or add to accumulators: if (apx >= 40 && apx <= 160) { this.wheel_delta_x = px > 0 ? 120 : -120; } else { this.wheel_delta_x += px; } if (apy >= 40 && apy <= 160) { this.wheel_delta_y = py > 0 ? 120 : -120; } else { this.wheel_delta_y += py; } //send synthetic click+release as many times as needed: let wx = Math.abs(this.wheel_delta_x); let wy = Math.abs(this.wheel_delta_y); const button_x = this.wheel_delta_x >= 0 ? 7 : 6; const button_y = this.wheel_delta_y >= 0 ? 5 : 4; while (wx >= 120) { wx -= 120; this.send([PACKET_TYPES.button_action, wid, button_x, true, coords, modifiers, buttons]); this.send([PACKET_TYPES.button_action, wid, button_x, false, coords, modifiers, buttons]); } while (wy >= 120) { wy -= 120; this.send([PACKET_TYPES.button_action, wid, button_y, true, coords, modifiers, buttons]); this.send([PACKET_TYPES.button_action, wid, button_y, false, coords, modifiers, buttons]); } //store left overs: this.wheel_delta_x = this.wheel_delta_x >= 0 ? wx : -wx; this.wheel_delta_y = this.wheel_delta_y >= 0 ? wy : -wy; e.preventDefault(); return false; } init_clipboard() { this.clog("initializing clipboard: enabled=", this.clipboard_enabled, ", poll=", this.clipboard_poll, ", preferred format=", this.clipboard_preferred_format); if (!this.clipboard_enabled) { return; } window.addEventListener("paste", (e) => { let clipboardData = (e.originalEvent || e).clipboardData; if (clipboardData && clipboardData.files && clipboardData.files.length > 0) { const files = clipboardData.files; this.clog("paste got", files.length, "files"); for (let index = 0; index < files.length; index++) { const file = files.item(index); this.send_file(file); } e.preventDefault(); return; } const fmt = this.clipboard_preferred_format; if ((fmt === TEXT_PLAIN || fmt === UTF8_STRING) && navigator.clipboard && navigator.clipboard.readText) { navigator.clipboard.readText().then( (text) => { this.cdebug("clipboard", "paste event, text=", text); this.clipboard_buffer = text; const data = Utilities.StringToUint8(text); this.send_clipboard_token(data); }, (error) => this.cdebug("clipboard", "paste event failed:", error) ); return; } let clipboard_buffer = clipboardData.getData(fmt); if (clipboard_buffer) { this.cdebug("clipboard", "paste event, ", fmt, "=", clipboard_buffer); this.clipboard_buffer = clipboard_buffer; const data = Utilities.StringToUint8(clipboard_buffer); this.send_clipboard_token(data, [fmt]); } }); window.addEventListener("copy", (e) => { const clipboard_buffer = this.get_clipboard_buffer(); const pasteboard = $(PASTEBOARD_SELECTOR); pasteboard.text(clipboard_buffer); pasteboard.select(); this.cdebug("clipboard", "copy event, clipboard buffer=", clipboard_buffer); this.clipboard_pending = false; }); window.addEventListener("cut", (e) => { const clipboard_buffer = this.get_clipboard_buffer(); const pasteboard = $(PASTEBOARD_SELECTOR); pasteboard.text(clipboard_buffer); pasteboard.select(); this.cdebug("clipboard", "cut event, clipboard buffer=", clipboard_buffer); this.clipboard_pending = false; }); const screen = $("#screen"); screen.on("click", (e) => this.may_set_clipboard()); screen.keypress(() => this.may_set_clipboard()); } may_set_clipboard(e) { this.cdebug("clipboard", "pending=", this.clipboard_pending, "buffer=", truncate(this.clipboard_buffer)); if (!this.clipboard_pending) { return; } let clipboard_buffer = this.get_clipboard_buffer(); const clipboard_datatype = (this.get_clipboard_datatype() || "").toLowerCase(); const is_text = clipboard_datatype.includes("text") || clipboard_datatype.includes("string"); if (!is_text) { //maybe just abort here instead? clipboard_buffer = ""; } const pasteboard = $(PASTEBOARD_SELECTOR); pasteboard.text(clipboard_buffer); pasteboard.select(); this.cdebug("clipboard", "click event, with pending clipboard datatype=", clipboard_datatype, ", buffer=", clipboard_buffer ); let success = false; if ( Object.hasOwn(window, "clipboardData") && Object.hasOwn(window.clipboardData, "setData") && typeof window.clipboardData.setData === "function" ) { try { window.clipboardData.setData(clipboard_datatype, clipboard_buffer); success = true; } catch { success = false; } } if (!success && is_text) { success = document.execCommand("copy"); } else { //probably no point in trying again? } if (success) { //clipboard_buffer may have been cleared if not set to text: this.clipboard_buffer = clipboard_buffer; this.clipboard_pending = false; } } _poll_clipboard(e) { if (!this.clipboard_poll) { return; } //see if the clipboard contents have changed: if (this.clipboard_pending) { //we're still waiting to set the clipboard.. return false; } this.read_clipboard(e); } read_clipboard(e) { if (this.clipboard_enabled === false) { return; } if (navigator.clipboard && navigator.clipboard.clipboardData) { this.debug("clipboard", "polling using", navigator.clipboard.clipboardData); this.read_clipboard_data(); return false; } if (navigator.clipboard && navigator.clipboard.readText) { this.debug("clipboard", "polling using", navigator.clipboard.readText); this.read_clipboard_text(); return false; } //fallback code for legacy mode: let datatype = TEXT_PLAIN; let clipboardData = (e.originalEvent || e).clipboardData; if (!clipboardData) { this.debug("clipboard", "polling: no data available"); return false; } const raw_clipboard_buffer = clipboardData.getData(datatype); if (raw_clipboard_buffer === null) { return false; } const clipboard_buffer = unescape(encodeURIComponent(raw_clipboard_buffer)); this.debug("clipboard", "paste event, data=", clipboard_buffer); if (clipboard_buffer === this.clipboard_buffer) { return false; } this.debug("clipboard", "clipboard contents have changed"); this.clipboard_buffer = clipboard_buffer; this.send_clipboard_token(clipboard_buffer, [datatype]); this.clipboard_delayed_event_time = performance.now() + CLIPBOARD_EVENT_DELAY; return true; } read_clipboard_data() { if (this.clipboard_enabled === false) { return; } navigator.clipboard.clipboardData.read(TEXT_HTML).then( (text) => { this.debug("clipboard", "paste event, text/html=", text); if (!text) { // try with plain text: read_clipboard_text(); return; } const clipboard_buffer = text; if (clipboard_buffer !== this.clipboard_buffer) { this.debug("clipboard", "clipboard contents have changed"); this.clipboard_buffer = clipboard_buffer; this.send_clipboard_token(clipboard_buffer, [TEXT_HTML]); this.clipboard_delayed_event_time = performance.now() + CLIPBOARD_EVENT_DELAY; } this.clipboard_pending = false; }, (error) => { this.debug("clipboard", "paste event failed:", error); this.clipboard_pending = false; } ); } read_clipboard_text() { if (this.clipboard_enabled === false) { return; } client.debug("clipboard", "read_clipboard_text()"); //warning: this can take a while, //so we may send the click before the clipboard contents... navigator.clipboard.readText().then( (text) => { this.debug("clipboard", "paste event, text=", text); const clipboard_buffer = unescape(encodeURIComponent(text)); if (clipboard_buffer !== this.clipboard_buffer) { this.debug("clipboard", "clipboard contents have changed"); this.clipboard_buffer = clipboard_buffer; this.send_clipboard_token(clipboard_buffer); this.clipboard_delayed_event_time = performance.now() + CLIPBOARD_EVENT_DELAY; } this.clipboard_pending = false; }, (error) => { this.debug("clipboard", "paste event failed:", error); this.clipboard_pending = false; } ); } /** * Focus */ set_focus(win) { if (!win || this.server_readonly || !this.connected) { return; } // don't send focus packet for override_redirect windows! if (win.override_redirect || win.tray) { return; } if (win.minimized) { //tell server to map it: win.toggle_minimized(); } // keep modal windows on top: let modal = false; if (MODAL_FOCUS) { for (const index in this.id_to_window) { modal = modal || this.id_to_window[index].metadata.modal; } } if (modal && !win.metadata.modal) { // don't take focus from a modal window return; } const wid = win.wid; if (this.focused_wid === wid) { return; } // Keep DESKTOP-type windows per default settings lower than all other windows. // Only allow focus if all other windows are minimized. if ( default_settings !== undefined && default_settings.auto_fullscreen_desktop_class !== undefined && default_settings.auto_fullscreen_desktop_class.length > 0 ) { const auto_fullscreen_desktop_class = default_settings.auto_fullscreen_desktop_class; if ( win.windowtype === "DESKTOP" && win.metadata["class-instance"].includes(auto_fullscreen_desktop_class) ) { for (const index in this.id_to_window) { const otherwin = this.id_to_window[index]; if (otherwin.wid !== win.wid && !otherwin.minimized) { return; } } } } const top_stacking_layer = Object.keys(this.id_to_window).length; const old_stacking_layer = win.stacking_layer; const had_focus = this.focused_wid; this.focused_wid = wid; this.send([PACKET_TYPES.focus, wid, []]); //set the focused flag on the window specified, //adjust stacking order: let iwin = null; for (const index in this.id_to_window) { iwin = this.id_to_window[index]; iwin.focused = iwin.wid === wid; if (iwin.focused) { iwin.stacking_layer = top_stacking_layer; this.send_configure_window(iwin, { focused: true }, true); } else { //move it down to fill the gap: if (iwin.stacking_layer > old_stacking_layer) { iwin.stacking_layer--; } if (had_focus === index) { this.send_configure_window(iwin, { focused: false }, true); } } iwin.updateFocus(); iwin.update_zindex(); } } /* * detect DESKTOP-type window from settings */ is_window_desktop(win) { if ( default_settings !== undefined && default_settings.auto_fullscreen_desktop_class !== undefined && default_settings.auto_fullscreen_desktop_class.length > 0 ) { const auto_fullscreen_desktop_class = default_settings.auto_fullscreen_desktop_class; if ( win.windowtype === "DESKTOP" && win.metadata["class-instance"].includes(auto_fullscreen_desktop_class) ) { return true; } } return false; } /* * Show/Hide the window preview list */ toggle_window_preview(init_callback) { const preview_element = $(WINDOW_PREVIEW_SELECTOR); preview_element.on("init", (e, slick) => { if (init_callback) { init_callback(e, slick); } }); preview_element.on("afterChange", (event, slick, currentSlide) => { const wid = $(".slick-current .window-preview-item-container").data("wid"); const win = this.id_to_window[wid]; if (!win.minimized) { win.focus(); } }); $(window).on("click", this._handle_window_list_blur); $(window).on("contextmenu", this._handle_window_list_blur); if (preview_element.is(":visible")) { // Restore the current selection if it's minimized. const wid = $(".slick-current .window-preview-item-container").data("wid"); this.clog(`current wid: ${wid}`); const win = this.id_to_window[wid]; if (win.minimized) { win.focus(); } // Clear the list of window elements. preview_element.children().remove(); preview_element.slick("unslick"); preview_element.children().remove(); preview_element.hide(); preview_element.off("afterChange"); preview_element.off("init"); $(window).off("click", this._handle_window_list_blur); $(window).off("contextmenu", this._handle_window_list_blur); return; } // Clear the list of window elements. preview_element.children().remove(); // Sort windows by stacking order.; const windows_sorted = Object.values(client.id_to_window).filter((win) => { // skip DESKTOP type windows. return !client.is_window_desktop(win); }); if (windows_sorted.length === 0) { return; } const container_width = 200 * Math.min(4, windows_sorted.length); preview_element.css("width", `${container_width}px`); windows_sorted.sort((a, b) => { if (a.stacking_layer < b.stacking_layer) { return 1; } if (a.stacking_layer > b.stacking_layer) { return -1; } return 0; }); // Add all open windows to the list. for (const index in windows_sorted) { const win = windows_sorted[index]; const item_container = $("
"); item_container.data("wid", win.wid); item_container.addClass("window-preview-item-container"); // Text const item_text_element = $("
"); item_text_element.addClass("window-preview-item-text"); item_text_element.text(win.title); // Window image const png_base64 = win.canvas.toDataURL("image/png"); const img_element = $(""); img_element.addClass("window-preview-item-img"); img_element.attr("src", png_base64); item_container.append(item_text_element); item_container.append(img_element); preview_element.append(item_container); } preview_element.show(); preview_element.slick({ centerMode: true, focusOnSelect: true, focusOnChange: true, touchMove: false, centerPadding: "0px", slidesToShow: Math.max(1, Math.min(4, windows_sorted.length)), slidesToScroll: 1, infinite: true, adaptiveHeight: false, speed: 0, prevArrow: null, nextArrow: null, easing: "null", waitForAnimate: false, }); } /* * Handle closing of window list if clickout outside of area. */ _handle_window_list_blur(e) { if ($(WINDOW_PREVIEW_SELECTOR).is(":visible")) { if (e.target.id === WINDOW_PREVIEW_SELECTOR.slice(1)) { return; } if ($(e.target).parents(WINDOW_PREVIEW_SELECTOR).length > 0) { return; } if ($(e.target).hasClass("window-list-button")) { return; } if ( $(e.target).parents(FLOAT_MENU_SELECTOR).length > 0 && $(e.target).parent().has("#open_windows_list") ) { return; } // Clicked outside window list, close it. client.toggle_window_preview(); } } /* * packet processing functions start here */ on_open() { //this hook can be overriden } _process_open() { this.cancel_open_timer(); // call the send_hello function this.on_connection_progress("WebSocket connection established", "", 60); // wait timeout seconds for a hello, then bomb this._send_hello(); this.on_open(); } schedule_open_timer() { this.cancel_open_timer(); this.open_timer = setTimeout(() => { let reconnect = this.reconnect || this.reconnect_attempt < this.reconnect_count; if (reconnect) { this.close_protocol(); this.reconnect_attempt++; this.do_reconnect(); } else { this.disconnect_reason = "failed to open connection"; this.close(); } }, this.OPEN_TIMEOUT); } cancel_open_timer() { if (this.open_timer) { clearTimeout(this.open_timer); this.open_timer = null; } } schedule_hello_timer() { this.cancel_hello_timer(); this.hello_timer = setTimeout(() => { this.disconnect_reason = "Did not receive hello before timeout reached, not an Xpra server?"; this.close(); }, this.HELLO_TIMEOUT); } cancel_hello_timer() { if (this.hello_timer) { clearTimeout(this.hello_timer); this.hello_timer = null; } } _process_error(packet) { const code = Number.parseInt(packet[2]); let reconnect = this.reconnect || this.reconnect_attempt < this.reconnect_count; if (reconnect && [0, 1006, 1008, 1010, 1014, 1015].includes(code)) { // don't re-connect unless we had actually managed to connect // (because these specific websocket error codes are likely permanent) reconnect = this.connected; } this.cerror("websocket error: ", packet[1], "code: ", code, "reason: ", this.disconnect_reason, ", connected: ", this.connected, ", reconnect: ", reconnect); if (this.reconnect_in_progress) { return; } this.packet_disconnect_reason(packet); this.close_audio(); if (!reconnect) { // call the client's close callback this.close(); } } packet_disconnect_reason(packet) { if (!this.disconnect_reason && packet[1]) { const code = packet[2]; if (!this.connected && [0, 1006, 1008, 1010, 1014, 1015].includes(code)) { this.disconnect_reason = "connection failed, invalid address?"; } else { this.disconnect_reason = packet[1]; let index = 2; while (packet.length > index && packet[index]) { this.disconnect_reason += `\n${packet[index]}`; index++; } } } } do_reconnect() { //try again: this.reconnect_in_progress = true; setTimeout(() => { try { this.remove_windows(); this.close_audio(); this.cancel_all_files(); this.clear_timers(); this.init_state(); this.close_protocol(); this.emit_connection_lost(); this.connect(); } finally { this.reconnect_in_progress = false; } }, this.reconnect_delay); } _process_close(packet) { this.clog("websocket closed: ", packet[1], "reason: ", this.disconnect_reason, ", reconnect: ", this.reconnect, ", reconnect attempt: ", this.reconnect_attempt); if (this.reconnect_in_progress) { return; } this.packet_disconnect_reason(packet); if (this.reconnect && this.reconnect_attempt < this.reconnect_count) { this.emit_connection_lost(); this.close_protocol(); this.reconnect_attempt++; this.do_reconnect(); } else { this.close(); } } disconnect(reason) { this.disconnect_reason = reason || "unknown"; this.close(); } close() { if (this.reconnect_in_progress) { return; } this.clog("client closed"); this.cancel_open_timer(); this.cancel_hello_timer(); this.cancel_all_files(); this.emit_connection_lost(); this.remove_windows(); this.close_audio(); this.clear_timers(); this.close_protocol(); // call the client's close callback this.callback_close(this.disconnect_reason); } _process_disconnect(packet) { this.debug("main", "disconnect reason:", packet[1]); if (this.reconnect_in_progress) { return; } // save the disconnect reason this.packet_disconnect_reason(packet); this.close(); // call the client's close callback this.callback_close(this.disconnect_reason); } _process_startup_complete(packet) { this.log("startup complete"); this.emit_connection_established(); } _connection_change(e) { const ci = Utilities.getConnectionInfo(); this.clog("connection status - change event=", e, ", connection info=", ci, "tell server:", this.server_connection_data); if (ci && this.server_connection_data) { this.send([PACKET_TYPES.connection_data, ci]); } } _process_hello(packet) { this.cancel_open_timer(); this.cancel_hello_timer(); const hello = packet[1]; this.clog("received hello capabilities", hello); if (!hello["rencodeplus"]) { throw "no common packet encoders, 'rencodeplus' is required by this client"; } this.session_name = hello["session_name"]; $("title").text(this.session_name); this.server_display = hello["display"] || ""; this.server_platform = hello["platform"] || ""; this.server_remote_logging = hello["remote-logging.multi-line"]; if (this.server_remote_logging && this.remote_logging) { //hook remote logging: Utilities.log = () => this.log(arguments); Utilities.warn = () => this.warn(arguments); Utilities.error = () => this.error(arguments); Utilities.exc = () => this.exc(arguments); } // check for server encryption caps update if (this.encryption) { this.cipher_out_caps = hello["encryption"]; console.info("cipher out caps=", JSON.stringify(this.cipher_out_caps)); this.protocol.set_cipher_out(this.cipher_out_caps, this.encryption_key); } const version = Utilities.s(hello["version"]); try { const vparts = version.split("."); const vno = vparts.map((x) => Number.parseInt(x)); if (vno[0] <= 0 && vno[1] < 10) { this.disconnect(`unsupported version: ${version}`); this.close(); return; } } catch { this.disconnect(`error parsing version number '${version}'`); this.close(); return; } this.log("got hello: server version", version, "accepted our connection"); // stuff that must be done after hello this._process_modifier_keycodes(hello["modifier_keycodes"] || {}); this._process_audio_caps(hello["audio"] || {}); if (SHOW_START_MENU) { this.xdg_menu = hello["xdg-menu"]; if (this.xdg_menu) { this.process_xdg_menu(); } } if (!hello["client-shutdown"]) { $("#shutdown_menu_entry").hide(); } if ( !this.file_transfer || (!hello["file-transfer"] && (!hello["file"] || !hello["file"]["enabled"])) ) { $("#upload_menu_entry").hide(); $("#download_menu_entry").hide(); } this.server_is_desktop = Boolean(hello["desktop"]); this.server_is_shadow = Boolean(hello["shadow"]); this.server_readonly = Boolean(hello["readonly"]); if (this.server_is_desktop || this.server_is_shadow) { jQuery("body").addClass("desktop"); } this.server_resize_exact = hello["resize_exact"] || false; this.server_screen_sizes = hello["screen-sizes"] || []; this.clog("server screen sizes:", this.server_screen_sizes); this.server_precise_wheel = hello["wheel.precise"] || false; this.remote_open_files = Boolean(hello["open-files"]); this.remote_file_transfer = Boolean(hello["file-transfer"]); this.remote_printing = Boolean(hello["printing"]); if (this.remote_printing && this.printing) { // send our printer definition const printers = { "HTML5 client": { "printer-info": "Print to PDF in client browser", "printer-make-and-model": "HTML5 client version", mimetypes: ["application/pdf"], }, }; this.send([PACKET_TYPES.printers, printers]); } this.server_connection_data = hello["connection-data"]; if (Object.hasOwn(navigator, "connection")) { navigator.connection.addEventListener("change", this._connection_change); this._connection_change(); } // don't send clipboard packets to servers that don't want them: this.clipboard_enabled = Boolean(hello["clipboard"] || false); // file transfer attributes: this.remote_file_size_limit = hello["max-file-size"]; this.remote_file_chunks = Math.max(0, Math.min(this.remote_file_size_limit, hello["file-chunks"] || 0)); // start sending our own pings this._send_ping(); this.ping_timer = setInterval(() => this._send_ping(), this.PING_FREQUENCY); this.reconnect_attempt = 0; // Drop start_new_session to avoid creating new displays // on reconnect this.start_new_session = null; this.on_connection_progress("Session started", "", 100); this.on_connect(); this.connected = true; this.send_keymap(); } _process_control(packet) { const action = packet[1]; console.info("control: ", action, packet); if (action === "log") { this.clog("log action:", packet); } else if (action === "redraw") { this.redraw_windows() } else if (action === "stop-audio") { this.close_audio(); } else if (action === "toggle-keyboard") { toggle_keyboard(); } else if (action === "toggle-float-menu") { toggle_float_menu(); } else if (action === "toggle-window-preview") { toggle_window_preview(); } else { this.cerror("unhandled control action:", action); } } _process_modifier_keycodes(modifier_keycodes) { // find the modifier to use for Num_Lock if (!modifier_keycodes) { return; } for (const modifier in modifier_keycodes) { if (Object.hasOwn(modifier_keycodes, modifier)) { const mappings = modifier_keycodes[modifier]; for (const keycode in mappings) { const keys = mappings[keycode]; for (const index in keys) { const key = keys[index]; if (key === "Num_Lock") { this.num_lock_modifier = modifier; } else if (key === "Alt_L") { this.alt_modifier = modifier; } else if (key === "Meta_L") { this.meta_modifier = modifier; } else if (key === "ISO_Level3_Shift" || key === "Mode_switch") { this.altgr_modifier = modifier; } else if (key === "Control_L") { this.control_modifier = modifier; } } } } } } _process_audio_caps(audio_caps) { if (!this.audio_enabled) { this.on_audio_state_change("disabled", ""); return; } if (!audio_caps["send"]) { this.audio_enabled = false; this.on_audio_state_change("disabled", "server does not support speaker forwarding"); return; } this.server_audio_codecs = audio_caps["encoders"]; if (!this.server_audio_codecs) { this.audio_enabled = false; this.on_audio_state_change("disabled", "audio codecs missing on the server"); return; } this.log("audio codecs supported by the server:", this.server_audio_codecs); if (!this.server_audio_codecs.includes(this.audio_codec)) { this.warn(`audio codec ${this.audio_codec} is not supported by the server`); this.audio_codec = null; //find the best codec we can use: for (let codec of MediaSourceConstants.PREFERRED_CODEC_ORDER) { if (codec in this.audio_codecs && this.server_audio_codecs.includes(codec)) { this.audio_framework = this.mediasource_codecs[codec] ? "mediasource" : "aurora"; this.audio_codec = codec; this.log("using", this.audio_framework, "audio codec", codec); break; } } if (!this.audio_codec) { this.audio_enabled = false; this.on_audio_state_change("disabled", "no matching audio codec"); return; } } //with Firefox, we have to wait for a user event.. if (this.audio_enabled && !Utilities.isFirefox()) { this._sound_start_receiving(); } } _process_encodings(packet) { const caps = packet[1]; this.log("update encodings:", Object.keys(caps)); } process_xdg_menu() { this.log("received xdg start menu data"); let key; //remove current menu: $("#startmenu li").remove(); const startmenu = document.querySelector("#startmenu"); for (key in this.xdg_menu) { const category = this.xdg_menu[key]; const li = document.createElement("li"); li.className = "-hasSubmenu"; const catDivLeft = document.createElement("div"); catDivLeft.className = "menu-divleft"; catDivLeft.append(this.xdg_image(category.IconData, category.IconType)); const a = document.createElement("a"); a.append(catDivLeft); a.append(document.createTextNode(this.xdg_menu[key].Name)); a.href = "#"; li.append(a); const ul = document.createElement("ul"); //TODO need to figure out how to do this properly a.addEventListener("mouseenter", function() { this.parentElement.childNodes[1].className = "-visible"; }); a.addEventListener("mouseleave", function() { this.parentElement.childNodes[1].className = ""; }); const xdg_menu_cats = category.Entries; for (key in xdg_menu_cats) { const entry = xdg_menu_cats[key]; const li2 = document.createElement("li"); const a2 = document.createElement("a"); let name = entry.Name; name = Utilities.trimString(name, 15); let command = ""; if (entry.Exec) { command = entry.Exec.replace(/%[FUfu]/g, ""); } const divLeft = document.createElement("div"); divLeft.className = "menu-divleft"; divLeft.append(this.xdg_image(entry.IconData, entry.IconType)); const titleDiv = document.createElement("div"); titleDiv.append(document.createTextNode(name)); titleDiv.className = "menu-content-left"; divLeft.append(titleDiv); a2.append(divLeft); a2.title = command; const me = this; a2.addEventListener("click", function() { const ignore = "False"; me.start_command(this.innerText, this.title, ignore); document.querySelector("#menu_list").className = "-hide"; }); a2.addEventListener("mouseenter", function() { this.parentElement.parentElement.className = "-visible"; }); a2.addEventListener("mouseleave", function() { this.parentElement.parentElement.className = ""; }); li2.append(a2); ul.append(li2); } li.append(ul); startmenu.append(li); } if (this.xdg_menu.length === 0) { startmenu.css("box-shadow", "none"); } } _process_setting_change(packet) { const setting = packet[1]; const value = packet[2]; if ((setting === "xdg-menu" || setting === "menu") && SHOW_START_MENU) { this.xdg_menu = value; if (this.xdg_menu) { this.process_xdg_menu(); $("#startmenuentry").show(); } } else if (setting === "session_name") { this.session_name = value; jQuery("title").text(value); } } xdg_image(icon_data, icon_type) { const img = new Image(); if (typeof icon_data !== "undefined") { let image_type = "image/" + icon_type; if (icon_type == "svg") { image_type = "image/svg+xml"; } img.src = "data:" + image_type + ";base64," + Utilities.ArrayBufferToBase64(icon_data); } img.className = "menu-content-left"; img.height = 24; img.width = 24; return img; } on_connect() { //this hook can be overriden } _process_challenge(packet) { this.cancel_open_timer(); this.cancel_hello_timer(); if (this.encryption) { if (packet.length >= 3) { this.cipher_out_caps = packet[2]; this.protocol.set_cipher_out(this.cipher_out_caps, this.encryption_key); } else { this.disconnect("challenge does not contain encryption details to use for the response"); return; } } const digest = packet[3]; const server_salt = Uint8ToString(packet[1]); const salt_digest = packet[4] || "xor"; const prompt = (packet[5] || "password").replace(/[^\d+,. /:a-z]/gi, ""); this.clog("process challenge:", digest); const client = this; function call_do_process_challenge(password) { Utilities.clog("call_do_process_challenge(", password, ")"); if (!client || !client.protocol) { return; } if (!password) { client.disconnect("password prompt cancelled"); return; } client.do_process_challenge(digest, server_salt, salt_digest, password); } if (this.passwords.length > 0) { if (!this.is_digest_safe(digest)) { this.disconnect("refusing to send a password over an insecure connection"); return; } const password = this.passwords.shift(); call_do_process_challenge(password); return; } if (digest.startsWith("keycloak") && this.keycloak_prompt_fn) { this.keycloak_prompt_fn(server_salt, call_do_process_challenge); return; } if (this.password_prompt_fn && this.is_digest_safe(digest)) { if (!this.is_digest_safe(digest)) { this.disconnect("refusing to prompt for a password over an insecure connection"); return; } const address = `${client.host}:${client.port}`; this.password_prompt_fn(`The server at ${address} requires a ${prompt}`, call_do_process_challenge); return; } this.disconnect("No password specified for authentication challenge"); } is_digest_safe(digest) { return digest !== "xor" || this.ssl || this.encryption || this.insecure || Utilities.isSafeHost(this.host); } do_process_challenge(digest, server_salt, salt_digest, password) { let client_salt = null; let l = server_salt.length; //don't use xor over unencrypted connections unless explicitly allowed: if (!this.is_digest_safe(digest)) { this.disconnect(`server requested digest xor, cowardly refusing to use it without encryption with ${this.host}`); return; } if (salt_digest === "xor") { if (l < 16 || l > 256) { this.disconnect(`invalid server salt length for xor digest:${l}`); return; } } else { //other digest, 32 random bytes is enough: l = 32; } const challenge_digest = digest.startsWith("keycloak") ? "xor" : digest; client_salt = Utilities.getSecureRandomString(l); this.clog("combining client salt:", Utilities.convertToHex(client_salt)); this.clog("with server salt:", Utilities.convertToHex(server_salt)); this.clog("using", salt_digest); Utilities.gendigest(salt_digest, client_salt, server_salt) .then(salt => { const hex_salt = Utilities.convertToHex(salt); this.clog(salt_digest, ":", hex_salt); if (challenge_digest == "xor") { // shortcut: no need for async API, do not convert the result to hex const xored = Utilities.xor(password, hex_salt); this.do_send_hello(xored, client_salt); return; } Utilities.gendigest(challenge_digest, password, hex_salt) .then(challenge_response => { const hex_challenge = Utilities.convertToHex(challenge_response); this.do_send_hello(hex_challenge, client_salt) }) .catch(err => { this.cerror("challenge digest error", err); this.disconnect("failed to generate challenge response " + err); }); }) .catch(err => { this.cerror("salt digest error", err); this.disconnect("failed to generate challenge response " + err); }); } _send_ping() { if (this.reconnect_in_progress || !this.connected) { return; } const now_ms = Math.ceil(performance.now()); this.send([PACKET_TYPES.ping, now_ms]); // add timeout to wait for ping timout this.ping_timeout_timer = setTimeout( () => this._check_echo_timeout(now_ms), this.PING_TIMEOUT ); // add timeout to detect temporary ping miss for spinners const wait = 2000; this.ping_grace_timer = setTimeout( () => this._check_server_echo(now_ms), wait ); } _process_ping(packet) { const echotime = packet[1]; this.last_ping_server_time = echotime; if (packet.length > 2) { //prefer system time (packet[1] is monotonic) this.last_ping_server_time = packet[2]; } let sid = ""; if (packet.length >= 4) { sid = packet[3]; } this.last_ping_local_time = Date.now(); const l1 = 0; const l2 = 0; const l3 = 0; this.send([PACKET_TYPES.ping_echo, echotime, l1, l2, l3, 0, sid]); } _process_ping_echo(packet) { this.last_ping_echoed_time = packet[1]; const l1 = packet[2]; const l2 = packet[3]; const l3 = packet[4]; this.client_ping_latency = packet[5]; this.server_ping_latency = Math.ceil(performance.now()) - this.last_ping_echoed_time; this.server_load = [l1 / 1000, l2 / 1000, l3 / 1000]; // make sure server goes OK immediately instead of waiting for next timeout this._check_server_echo(0); } /** * Info */ start_info_timer() { if (this.info_timer) { return; } this.info_timer = setInterval(() => { if (this.info_timer) { this.send_info_request(); } }, this.INFO_FREQUENCY); } send_info_request() { if (!this.info_request_pending) { this.send([PACKET_TYPES.info_request, [this.uuid], [], [] ]); this.info_request_pending = true; } } _process_info_response(packet) { this.info_request_pending = false; this.server_last_info = packet[1]; this.debug("network", "info-response:", this.server_last_info); const event = document.createEvent("Event"); event.initEvent("info-response", true, true); event.data = this.server_last_info; document.dispatchEvent(event); } stop_info_timer() { if (this.info_timer) { clearTimeout(this.info_timer); this.info_timer = null; this.info_request_pending = false; } } /** * System Tray forwarding */ position_float_menu() { const float_menu_element = $(FLOAT_MENU_SELECTOR); const toolbar_height = float_menu_element.height(); const toolbar_width = float_menu_element.width(); let left = float_menu_element.offset().left || 0; let top = float_menu_element.offset().top || 0; const screen = $("#screen"); const screen_width = screen.width(); const screen_height = screen.height(); if (this.toolbar_position === "custom") { //no calculations needed } else if (this.toolbar_position === "top-left") { left = 0; top = 0; } else if (this.toolbar_position === "top") { left = screen_width / 2 - toolbar_width / 2; } else if (this.toolbar_position === "top-right") { left = screen_width - toolbar_width; } else if (this.toolbar_position === "novnc") { left = 0; top = screen_height / 2 - toolbar_height / 2 - 100; } float_menu_element.offset({ top, left }); } _process_new_tray(packet) { const wid = packet[1]; const metadata = packet[4]; const mydiv = document.createElement("div"); mydiv.id = String(wid); const mycanvas = document.createElement("canvas"); mydiv.append(mycanvas); const float_tray = document.querySelector("#float_tray"); const float_menu = document.querySelector(FLOAT_MENU_SELECTOR); const float_menu_element = $(FLOAT_MENU_SELECTOR); float_menu_element.children().show(); //increase size for tray icon const new_width = float_menu_width + float_menu_item_size - float_menu_padding + 5; float_menu.style.width = `${new_width}px`; float_menu_width = float_menu_element.width() + 10; mydiv.style.backgroundColor = "white"; float_tray.append(mydiv); const x = 0; const y = 0; const w = float_menu_item_size; const h = float_menu_item_size; mycanvas.width = w; mycanvas.height = h; this.id_to_window[wid] = new XpraWindow(this, wid, x, y, w, h, metadata, false, true, {}, //TODO: send new tray geometry to the server using send_tray_configure () => this.debug("tray", "tray geometry changed (ignored)"), (event, window) => this.on_mousemove(event, window), (event, window) => this.on_mousedown(event, window), (event, window) => this.on_mouseup(event, window), (event, window) => this.on_mousescroll(event, window), () => this.debug("tray", "tray set focus (ignored)"), () => this.debug("tray", "tray closed (ignored)"), this.scale ); this.send_tray_configure(wid); } send_tray_configure(wid) { const div = jQuery(`#${wid}`); const x = Math.round(div.offset().left); const y = Math.round(div.offset().top); const w = float_menu_item_size; const h = float_menu_item_size; this.clog("tray", wid, "position:", x, y); this.send([PACKET_TYPES.configure_window, Number(wid), x, y, w, h, {}]); } reconfigure_all_trays() { const float_menu = document.querySelector(FLOAT_MENU_SELECTOR); let float_menu_width = float_menu_item_size * float_menu_item_count + float_menu_padding; for (const twid in this.id_to_window) { const twin = this.id_to_window[twid]; if (twin && twin.tray) { float_menu_width = float_menu_width + float_menu_item_size; this.send_tray_configure(twid); } } // only set if float_menu is visible if ($(FLOAT_MENU_SELECTOR).width() > 0) { float_menu.style.width = float_menu_width; this.position_float_menu(); } } suspend() { // this is not a power event, // usually just triggered by the `visibilitychange` listener, // we only want to tell the server to slow down the refresh rate, const options = { "batch": { "reset": true, "delay": 1000, "locked": true, "always": true, }, } this.clog("suspend event, sending control refresh with options=", options); this.send_control_refresh(1, options); for (const index in this.id_to_window) { const win = this.id_to_window[index]; win.suspend(); } } resume() { for (const index in this.id_to_window) { const win = this.id_to_window[index]; win.resume(); } const options = { "batch": { "reset": true, }, "refresh-now": true, } this.clog("resume event, sending control refresh with options=", options); this.send_control_refresh(100, options); this.redraw_windows(); } send_control_refresh(quality, options) { const wid = 0; const client_options = {}; const packet = [PACKET_TYPES.buffer_refresh, wid, 0, quality, options, client_options]; this.send(packet); } /** * Windows */ _new_window(wid, x, y, w, h, metadata, override_redirect, client_properties) { // each window needs their own DIV that contains a canvas const mydiv = document.createElement("div"); mydiv.id = String(wid); const screen = document.querySelector("#screen"); screen.append(mydiv); // create the XpraWindow object to own the new div const win = new XpraWindow(this, wid, x, y, w, h, metadata, override_redirect, false, client_properties, (window) => this.send_configure_window(window, {}, false), (event, window) => this.on_mousemove(event, window), (event, window) => this.on_mousedown(event, window), (event, window) => this.on_mouseup(event, window), (event, window) => this.on_mousescroll(event, window), (window) => this.set_focus(window), (window) => this.send_close_window(window), this.scale ); if (this.server_is_desktop || this.server_is_shadow) { window.noWindowList(); } else if (win && win.decorations) { const trimmedTitle = Utilities.trimString(win.title, 30); window.addWindowListItem(win, wid, trimmedTitle); } this.id_to_window[wid] = win; if (!override_redirect) { const geom = win.get_internal_geometry(); this.send([PACKET_TYPES.map_window, wid, geom.x, geom.y, geom.w, geom.h, win.client_properties]); this.set_focus(win); } } _new_window_common(packet, override_redirect) { const wid = packet[1]; let x = packet[2]; let y = packet[3]; let w = packet[4]; let h = packet[5]; const metadata = packet[6]; if (wid in this.id_to_window) throw new Error(`we already have a window ${wid}`); if (w <= 0 || h <= 0) { this.error("window dimensions are wrong:", w, h); w = 1; h = 1; } let client_properties = {}; if (packet.length >= 8) client_properties = packet[7]; if (x === 0 && y === 0 && !metadata["set-initial-position"] && !metadata["fullscreen"]) { //find a good position for it const l = Object.keys(this.id_to_window).length; if (l === 0) { //first window: center it if (w <= this.desktop_width) { x = Math.round((this.desktop_width - w) / 2); } if (h <= this.desktop_height) { y = Math.round((this.desktop_height - h) / 2); } } else { x = Math.min(l * 10, Math.max(0, this.desktop_width - 100)); y = 96; } } this._new_window(wid, x, y, w, h, metadata, override_redirect, client_properties); this._new_ui_event(); } send_configure_window(win, state, skip_geometry) { const geom = win.get_internal_geometry(); const wid = win.wid; const packet = [PACKET_TYPES.configure_window, wid, geom.x, geom.y, geom.w, geom.h, win.client_properties, 0, state, skip_geometry, ]; this.send(packet); } _process_new_window(packet) { this._new_window_common(packet, false); } _process_new_override_redirect(packet) { this._new_window_common(packet, true); } _process_window_metadata(packet) { const wid = packet[1]; const metadata = packet[2]; const win = this.id_to_window[wid]; if (win) { win.update_metadata(metadata); } } _process_initiate_moveresize(packet) { const wid = packet[1]; const win = this.id_to_window[wid]; if (!win) { this.log("cannot initiate moveresize, window", wid, "not found"); return; } const x_root = packet[2]; const y_root = packet[3]; const direction = packet[4]; const button = packet[5]; const source_indication = packet[6]; let event = this.mousedown_event; if (this.mouseup_event && direction === MOVERESIZE_CANCEL) { event = this.mouseup_event; } this.log("initiate moveresize on", win, "mouse event=", event); win.initiate_moveresize(event, x_root, y_root, direction, button, source_indication); } _process_pointer_position(packet) { const wid = packet[1]; let x = packet[2]; let y = packet[3]; const win = this.id_to_window[wid]; //we can use window relative coordinates: if (packet.length >= 6 && win) { const pos = win.get_internal_geometry(); x = pos.x + packet[4]; y = pos.y + packet[5]; } const shadow_pointer = document.querySelector("#shadow_pointer"); const style = shadow_pointer.style; let cursor_url; let w; let h; let xhot; let yhot; if (win.cursor_data) { cursor_url = win.cursor_data[0]; xhot = win.cursor_data[1]; yhot = win.cursor_data[2]; w = win.cursor_data[3]; h = win.cursor_data[4]; } else { w = 32; h = 32; xhot = 8; yhot = 3; cursor_url = "icons/default_cursor.png"; } x -= xhot; y -= yhot; style.width = `${w}px`; style.height = `${h}px`; shadow_pointer.src = cursor_url; style.left = `${x}px`; style.top = `${y}px`; style.display = "inline"; } on_last_window() { //this hook can be overriden } _process_lost_window(packet) { const wid = packet[1]; const win = this.id_to_window[wid]; if (win && win.decorations) { window.removeWindowListItem(wid); } try { delete this.id_to_window[wid]; } catch {} if (win) { win.destroy(); this.clog("lost window, was tray=", win.tray); if (win.tray) { //other trays may have moved: this.reconfigure_all_trays(); } } this.clog("lost window", wid, ", remaining: ", Object.keys(this.id_to_window)); if (Object.keys(this.id_to_window).length === 0) { this.on_last_window(); this.auto_focus(); } else if (win && win.focused) { //it had focus, find the next highest: this.auto_focus(); } if (this.decode_worker) { this.decode_worker.postMessage({cmd: "remove", wid}); } } auto_focus() { let highest_window = null; let highest_stacking = -1; let modal = false; if (MODAL_FOCUS) { for (const index in this.id_to_window) { const win = this.id_to_window[index]; modal = modal || win.metadata.modal; } } for (const index in this.id_to_window) { const win = this.id_to_window[index]; if (win.minimized || win.tray) { continue; } if (modal && !win.metadata.modal) { continue; } if (win.stacking_layer > highest_stacking) { highest_window = win; highest_stacking = win.stacking_layer; } } if (highest_window) { this.set_focus(highest_window); } else { this.focused_wid = 0; this.send([PACKET_TYPES.focus, 0, []]); } } _process_raise_window(packet) { const wid = packet[1]; const win = this.id_to_window[wid]; this.set_focus(win); } _process_window_resized(packet) { const wid = packet[1]; const width = packet[2]; const height = packet[3]; const win = this.id_to_window[wid]; if (win) { win.resize(width, height); } } _process_window_move_resize(packet) { const [, wid, x, y, width, height] = packet; this.id_to_window[wid]?.move_resize(x, y, width, height); } _process_configure_override_redirect(packet) { const [, wid, x, y, width, height] = packet; this.id_to_window[wid]?.move_resize(x, y, width, height); } _process_desktop_size(packet) { //we don't use this yet, //we could use this to clamp the windows to a certain area } _process_bell(packet) { const percent = packet[3]; const pitch = packet[4]; const duration = packet[5]; if (this.audio_context) { const oscillator = this.audio_context.createOscillator(); const gainNode = this.audio_context.createGain(); oscillator.connect(gainNode); gainNode.connect(this.audio_context.destination); gainNode.gain.setValueAtTime(percent, this.audio_context.currentTime); oscillator.frequency.setValueAtTime(pitch, this.audio_context.currentTime); oscillator.start(); setTimeout(() => oscillator.stop(), duration); } else { const snd = new Audio(BELL_SOUND); snd.play(); } } /** * Notifications */ _process_notify_show(packet) { //TODO: add UI switch to disable notifications const nid = packet[2]; const replaces_nid = packet[4]; const summary = Utilities.s(packet[6]); const body = Utilities.s(packet[7]); const expire_timeout = packet[8]; const icon = packet[9]; const actions = packet[10]; const hints = packet[11]; if (window.closeNotification) { if (replaces_nid > 0) { window.closeNotification(replaces_nid); } window.closeNotification(nid); } const context = this; function notify() { let icon_url = ""; if (icon && icon[0] === "png") { icon_url = `data:image/png;base64,${Utilities.ToBase64(icon[3])}`; context.clog("notification icon_url=", icon_url); } const notification = new Notification(summary, { body, icon: icon_url, }); const reason = 2; //closed by the user - best guess... notification.addEventListener("close", () => context.send([PACKET_TYPES.notification_close, nid, reason, ""]) ); notification.addEventListener("click", () => context.log("user clicked on notification", nid) ); } if ("Notification" in window && actions.length === 0) { //we have notification support in the browser if (Notification.permission === "granted") { notify(); return; } else if (Notification.permission !== "denied") { Notification.requestPermission(function(permission) { if (permission === "granted") { notify(); } }); return; } } if (window.doNotification) { window.doNotification("info", nid, summary, body, expire_timeout, icon, actions, hints, function(nid, action_id) { context.send([PACKET_TYPES.notification_action, nid, action_id]); }, function(nid, reason, text) { context.send([PACKET_TYPES.notification_close, nid, reason, text || ""]); } ); } context._new_ui_event(); } _process_notify_close(packet) { const nid = packet[1]; if (window.closeNotification) { window.closeNotification(nid); } } /** * Cursors */ reset_cursor() { for (const wid in this.id_to_window) { const win = this.id_to_window[wid]; win.reset_cursor(); } } _process_cursor(packet) { if (packet.length < 9) { this.reset_cursor(); return; } //we require a png encoded cursor packet: const encoding = packet[1]; if (encoding !== "png") { this.warn(`invalid cursor encoding: ${encoding}`); return; } const w = packet[4]; const h = packet[5]; const xhot = packet[6]; const yhot = packet[7]; const img_data = packet[9]; for (const wid in this.id_to_window) { const win = this.id_to_window[wid]; win.set_cursor(encoding, w, h, xhot, yhot, img_data); } } _process_window_icon(packet) { const wid = packet[1]; const w = packet[2]; const h = packet[3]; const encoding = packet[4]; const img_data = packet[5]; this.debug("main", "window-icon: ", encoding, " size ", w, "x", h); const win = this.id_to_window[wid]; if (win) { const source = win.update_icon(w, h, encoding, img_data); //update favicon too: if (wid === this.focused_wid || this.server_is_desktop || this.server_is_shadow) { jQuery("#favicon").attr("href", source); } } } /** * Window Painting */ _process_draw(packet) { //ensure that the pixel data is in a byte array: const coding = Utilities.s(packet[6]); let img_data = packet[7]; const raw_buffers = []; const now = performance.now(); if (coding !== "scroll") { raw_buffers.push(img_data.buffer); } if (this.decode_worker) { this.decode_worker.postMessage({cmd: "decode", packet, start: now}, raw_buffers); //the worker draw event will call do_process_draw } else { this.do_process_draw(packet, now); } } _process_eos(packet) { this.do_process_draw(packet, 0); const wid = packet[1]; if (this.decode_worker) { this.decode_worker.postMessage({cmd: "eos", wid}); } } request_redraw(win) { if (document.hidden) { this.debug("draw", "not redrawing, document.hidden=", document.hidden); return; } if (this.offscreen_api && this.decode_worker) { this.decode_worker.postMessage({cmd: "redraw", wid: win.wid}); return; } // request that drawing to screen takes place at next available opportunity if possible this.debug("draw", "request_redraw for", win); win.swap_buffers(); if (!window.requestAnimationFrame) { // requestAnimationFrame is not available, draw immediately win.draw(); return; } if (!this.pending_redraw.includes(win)) { this.pending_redraw.push(win); } if (this.draw_pending) { // already scheduled return; } // schedule a screen refresh if one is not already due: this.draw_pending = performance.now(); window.requestAnimationFrame(() => { this.draw_pending_list(); }); } draw_pending_list() { const elapsed = performance.now() - this.draw_pending; this.debug("draw", "animation frame:", this.pending_redraw.length, "windows to paint, processing delay", elapsed, "ms"); this.draw_pending = 0; // draw all the windows in the list: while (this.pending_redraw.length > 0) { const w = this.pending_redraw.shift(); w.draw(); } } do_send_damage_sequence(packet_sequence, wid, width, height, decode_time, message) { const protocol = this.protocol; if (!protocol) { return; } const packet = [PACKET_TYPES.damage_sequence, packet_sequence, wid, width, height, decode_time, message]; if (decode_time < 0) { this.cwarn("decode error packet:", packet); } protocol.send(packet); } do_process_draw(packet, start) { if (!packet) { //no valid draw packet, likely handle errors for that here return; } const ptype = packet[0]; const wid = packet[1]; const win = this.id_to_window[wid]; if (ptype === "eos") { this.debug("draw", "eos for window", wid); if (win) { win.eos(); } return; } const width = packet[4]; const height = packet[5]; const coding = Utilities.s(packet[6]); const packet_sequence = packet[8]; const options = packet[10] || {}; const protocol = this.protocol; if (!protocol) { return; } const me = this; function send_damage_sequence(decode_time, message) { me.do_send_damage_sequence(packet_sequence, wid, width, height, decode_time, message); } const client = this; function decode_result(error) { const flush = options["flush"] || 0; let decode_time = Math.round(1000 * performance.now() - 1000 * start); if (flush === 0) { client.request_redraw(win); } if (error || start === 0) { client.request_redraw(win); decode_time = -1; } client.debug("draw", "decode time for ", coding, " sequence ", packet_sequence, ": ", decode_time, ", flush=", flush); send_damage_sequence(decode_time, error || ""); } if (!win) { this.debug("draw", "cannot paint, window not found:", wid); send_damage_sequence(-1, `window ${wid} not found`); return; } if (coding === "offscreen-painted") { const decode_time = options["decode_time"]; send_damage_sequence(decode_time || 0, ""); return; } try { win.paint(packet, decode_result); } catch (error) { this.exc(error, "error painting", coding, "sequence no", packet_sequence); send_damage_sequence(-1, String(error)); //there may be other screen updates pending: win.paint_pending = 0; win.may_paint_now(); this.request_redraw(win); } } /** * Audio */ init_audio(ignore_audio_blacklist) { this.debug("audio", "init_audio() enabled=", this.audio_enabled, ", mediasource enabled=", this.audio_mediasource_enabled, ", aurora enabled=", this.audio_aurora_enabled); if (this.audio_mediasource_enabled) { this.mediasource_codecs = MediaSourceUtil.getMediaSourceAudioCodecs(ignore_audio_blacklist); for (const codec_option in this.mediasource_codecs) { this.audio_codecs[codec_option] = this.mediasource_codecs[codec_option]; } } if (this.audio_aurora_enabled) { this.aurora_codecs = MediaSourceUtil.getAuroraAudioCodecs(); for (const codec_option in this.aurora_codecs) { if (codec_option in this.audio_codecs) { //we already have native MediaSource support! continue; } this.audio_codecs[codec_option] = this.aurora_codecs[codec_option]; } } this.debug("audio", "codecs:", this.audio_codecs); if (!this.audio_codecs) { this.audio_codec = null; this.audio_enabled = false; this.warn("no valid audio codecs found"); return; } if (!(this.audio_codec in this.audio_codecs)) { if (this.audio_codec) { this.warn(`invalid audio codec: ${this.audio_codec}`); this.warn(`codecs found: ${this.audio_codecs}`); } this.audio_codec = MediaSourceUtil.getDefaultAudioCodec(this.audio_codecs); if (this.audio_codec) { if (this.audio_mediasource_enabled && this.audio_codec in this.mediasource_codecs) { this.audio_framework = "mediasource"; } else if (this.audio_aurora_enabled && !Utilities.isIE()) { this.audio_framework = "aurora"; } if (this.audio_framework) { this.log(`using ${this.audio_framework} audio codec: ${this.audio_codec}`); } else { this.warn("no valid audio framework - cannot enable audio"); this.audio_enabled = false; } } else { this.warn("no valid audio codec found"); this.audio_enabled = false; } } else { this.log(`using ${this.audio_framework} audio codec: ${this.audio_codec}`); } this.log("audio codecs: ", Object.keys(this.audio_codecs)); } _sound_start_receiving() { if (!this.audio_framework || !this.audio_codec) { //choose a codec + framework to use const codecs_supported = MediaSourceUtil.get_supported_codecs(this.audio_mediasource_enabled, this.audio_aurora_enabled, false); const audio_codec = MediaSourceUtil.get_best_codec(codecs_supported); if (!audio_codec) { this.log("no codec found"); return; } const acparts = audio_codec.split(":"); this.audio_framework = acparts[0]; this.audio_codec = acparts[1]; } try { this.audio_buffers = []; this.audio_buffers_count = 0; if (this.audio_framework === "mediasource") { this._sound_start_mediasource(); } else { this._sound_start_aurora(); } } catch (error) { this.exc(error, "error starting audio player"); } } _send_sound_start() { this.log(`audio: requesting ${this.audio_codec} stream from the server`); this.send([PACKET_TYPES.sound_control, "start", this.audio_codec]); } _sound_start_aurora() { this.audio_aurora_ctx = AV.Player.fromXpraSource(); this._send_sound_start(); } _sound_start_mediasource() { const me = this; function audio_error(event) { if (!me.media_source) { //already closed me.debug("audio", `media_source is closed, ignoring audio error: ${event}`); return; } if (me.audio) { me.error(`${event} error: ${me.audio.error}`); if (me.audio.error) { me.error(MediaSourceConstants.ERROR_CODE[me.audio.error.code]); } } else { me.error(`${event} error`); } me.close_audio(); } //Create a MediaSource: this.media_source = MediaSourceUtil.getMediaSource(); if (this.debug) { MediaSourceUtil.addMediaSourceEventDebugListeners(this.media_source, "audio"); } this.media_source.addEventListener("error", (e) => audio_error("audio source") ); //Create an