/* * Copyright (c) 2013 Antoine Martin * Copyright (c) 2014 Joshua Higgins * Copyright (c) 2015 Spikes, Inc. * Licensed under MPL 2.0 * * xpra window * * Based on shape.js but no longer requires it * * requires: * jQueryUI */ const TASKBAR_HEIGHT = 0; function dummy() { //this placeholder function does nothing } /** * This is the class representing a window we draw on the canvas. * It has a geometry, it may have borders and a top bar. * The contents of the window is an image, which gets updated * when we receive pixels from the server. */ class XpraWindow { constructor( client, wid, x, y, w, h, metadata, override_redirect, tray, client_properties, geometry_callback, mouse_move_callback, mouse_down_callback, mouse_up_callback, mouse_scroll_callback, set_focus_callback, window_closed_callback, scale ) { // use me in jquery callbacks as we lose 'this' this.client = client; //xpra specific attributes: this.wid = wid; //enclosing div in page DOM this.div = document.getElementById(wid); //these values represent the internal geometry //i.e. geometry as windows appear to the compositor this.x = x; this.y = y; this.w = w; this.h = h; // scaling for client display width override this.scale = scale; this.metadata = {}; this.override_redirect = override_redirect; this.tray = tray; this.has_alpha = false; this.client_properties = client_properties; this.set_focus_cb = set_focus_callback || dummy; this.mouse_move_cb = mouse_move_callback || dummy; this.mouse_down_cb = mouse_down_callback || dummy; this.mouse_up_cb = mouse_up_callback || dummy; this.mouse_scroll_cb = mouse_scroll_callback || dummy; this.geometry_cb = geometry_callback || dummy; this.window_closed_cb = window_closed_callback || dummy; this.debug_categories = client.debug_categories; this.canvas = null; this.init_canvas(); //window attributes: this.title = ""; this.windowtype = []; this.fullscreen = false; this.saved_geometry = null; this.minimized = false; this.maximized = false; this.focused = false; this.decorations = true; // whether the window should be decorated or not this.decorated = true; // whether it actually is (fullscreen windows are not) this.resizable = false; this.stacking_layer = 0; // Icon cache this.icon = null; // get offsets this.leftoffset = 0; this.rightoffset = 0; this.topoffset = 0; this.bottomoffset = 0; // update metadata that is safe before window is drawn this.update_metadata(metadata, true); // create the decoration as part of the window, style is in CSS jQuery(this.div).addClass("window"); for (const windowtype in this.windowtype) { jQuery(this.div).addClass(`window-${windowtype}`); } const fullscreen = (metadata["fullscreen"]) ?? false; if (this.client.server_is_desktop || this.client.server_is_shadow) { jQuery(this.div).addClass("desktop"); this.resizable = false; } else if (this.tray) { jQuery(this.div).addClass("tray"); this.resizable = false; } else if (this.override_redirect) { jQuery(this.div).addClass("override-redirect"); this.resizable = false; } else if (!fullscreen && ((this.windowtype.length === 0) || this.has_windowtype(["NORMAL", "DIALOG", "UTILITY"]))) { this.resizable = true; } this.configure_border_class(); this.add_headerbar(); this.make_draggable() this.update_offsets() // stop propagation if we're over the window: jQuery(this.div).mousedown((e) => e.stopPropagation()); //bug 2418: if we stop 'mouseup' propagation, //jQuery can't ungrab the window with Firefox // assign callback to focus window if header is clicked. jQuery(this.d_header).click((e) => { if ( !this.minimized && $(e.target).parents(".windowbuttons").length === 0 ) { this.focus(); } }); // create the spinner overlay div jQuery(this.div).prepend( `
` ); this.spinnerdiv = jQuery(`#spinner${wid}`); this.cursor_data = null; this.pointer_down = -1; this.pointer_last_x = 0; this.pointer_last_y = 0; // adapt to screen size if needed (ie: shadow / desktop windows): this.screen_resized(); // set the CSS geometry this.updateCSSGeometry(); // now read all metadata this.update_metadata(metadata); } log() { if (this.client) this.client.log.apply(this.client, arguments); } warn() { if (this.client) this.client.warn.apply(this.client, arguments); } error() { if (this.client) this.client.error.apply(this.client, arguments); } exc() { if (this.client) this.client.exc.apply(this.client, arguments); } debug() { if (this.client) this.client.debug.apply(this.client, arguments); } configure_border_class() { if (this.resizable || this.decorated) { jQuery(this.div).addClass("border"); } else { jQuery(this.div).removeClass("border"); } } update_offsets() { this.leftoffset = Number.parseInt(jQuery(this.div).css("border-left-width"), 10); this.rightoffset = Number.parseInt(jQuery(this.div).css("border-right-width"), 10); this.topoffset = Number.parseInt(jQuery(this.div).css("border-top-width"), 10) this.bottomoffset = Number.parseInt(jQuery(this.div).css("border-bottom-width"), 10); if (this.decorated) { this.topoffset = this.topoffset + Number.parseInt(jQuery(this.d_header).css("height"), 10); } this.debug("geometry", "decorated=", this.decorated, "offsets=", [this.leftoffset, this.topoffset, this.rightoffset, this.bottomoffset]); } add_headerbar() { const wid = this.wid; // add a title bar to this window if we need to // create header let head = `
` + `window icon ` + `${this.title} ` + ` `; if (!jQuery(this.div).hasClass("modal")) { //modal windows cannot be minimized (see #204) head += `minimize`; } head += `maximize ` + `close ` + `
`; jQuery(this.div).prepend(head); jQuery(`#head${wid}`).click(() => { if (!this.minimized) { this.focus(); } }); this.d_header = `#head${wid}`; this.d_closebtn = `#close${wid}`; this.d_maximizebtn = `#maximize${wid}`; this.d_minimizebtn = `#minimize${wid}`; if (this.resizable) { this.make_resizable(); jQuery(this.d_header).dblclick(() => this.toggle_maximized()); jQuery(this.d_closebtn).click(() => this.window_closed_cb(this)); jQuery(this.d_maximizebtn).click(() => this.toggle_maximized()); jQuery(this.d_minimizebtn).click(() => this.toggle_minimized()); } else { jQuery(this.d_maximizebtn).hide(); jQuery(`#windowlistitemmax${wid}`).hide(); } // we must set a sensible default early // so geometry calculations have the correct offset: if (!("decorations" in this.metadata)) { let decorated = true; if (this.override_redirect) { decorated = false; } else { decorated = !this.has_windowtype(["DROPDOWN", "TOOLTIP", "POPUP_MENU", "MENU", "COMBO"]); } this._set_decorated(decorated); // console.log("decorated=", decorated, "for windowtype=", this.windowtype); } } has_windowtype(windowtypes) { const windowtypes_set = new Set(windowtypes); return this.windowtype.some(element => windowtypes_set.has(element)); } make_draggable() { if (this.scale !== 1) { jQuery(this.div).draggable({ transform: true }); } jQuery(this.div).draggable({ cancel: "canvas" }); jQuery(this.div).on("dragstart", (event_) => { this.client.release_buttons(event_, this); this.focus(); this.client.mouse_grabbed = true; }); jQuery(this.div).on("dragstop", (event_, ui) => { this.client.mouse_grabbed = false; this.handle_moved(ui); }); } make_resizable() { // Use transform if scaled // This disables helper highlight, so we // move the resizable borders in transform plugin if (this.scale !== 1) { jQuery(this.div).resizable({ transform: true }); } // attach resize handles jQuery(this.div).resizable({ containment: "parent", helper: "ui-resizable-helper", handles: "n, e, s, w, ne, se, sw, nw", }); jQuery(this.div).on("resizestart", (evt) => { this.client.do_window_mouse_click(evt, this, false); this.client.mouse_grabbed = true; }); jQuery(this.div).on("resizestop", (evt, ui) => { this.handle_resized(ui); this.focus(); this.client.mouse_grabbed = false; //workaround for the window going blank, //just force a refresh: setTimeout(() => this.client.request_refresh(this.wid), 200); setTimeout(() => this.client.request_refresh(this.wid), 500); }); } init_canvas() { this.canvas = null; jQuery(this.div).find("canvas").remove(); const canvas = document.createElement("canvas"); if (this.client.try_gpu) { $(canvas).addClass("gpu-trigger"); } // set initial sizes canvas.width = this.w; canvas.height = this.h; this.canvas = canvas; this.div.append(canvas); if (this.client.offscreen_api && this.client.decode_worker) { // Transfer canvas control. this.transfer_canvas(canvas); } else { //we're going to paint from this class: this.canvas_ctx = this.canvas.getContext("2d"); this.canvas_ctx.imageSmoothingEnabled = false; this.init_offscreen_canvas(); this.draw_canvas = this.offscreen_canvas; this.paint_queue = []; this.paint_pending = 0; } this.register_canvas_mouse_events(this.canvas); this.register_canvas_pointer_events(this.canvas); } transfer_canvas(canvas) { const offscreen_handle = canvas.transferControlToOffscreen(); this.client.decode_worker.postMessage({ cmd: "canvas", wid: this.wid, canvas: offscreen_handle, debug: this.debug_categories.includes("draw"), }, [offscreen_handle] ); } init_offscreen_canvas() { this.offscreen_canvas = document.createElement("canvas"); this.offscreen_canvas.width = this.w; this.offscreen_canvas.height = this.h; this.offscreen_canvas_ctx = this.offscreen_canvas.getContext("2d"); this.offscreen_canvas_ctx.imageSmoothingEnabled = false; } swap_buffers() { //the up to date canvas is what we'll draw on screen: this.debug("draw", "swap_buffers"); this.draw_canvas = this.offscreen_canvas; this.init_offscreen_canvas(); this.offscreen_canvas_ctx.drawImage(this.draw_canvas, 0, 0); } register_canvas_mouse_events(canvas) { // Hook up the events we want to receive: jQuery(canvas).mousedown((e) => { return this.mouse_down_cb(e, this); }); jQuery(canvas).mouseup((e) => { return this.mouse_up_cb(e, this); }); jQuery(canvas).mousemove((e) => { return this.mouse_move_cb(e, this); }); } register_canvas_pointer_events(canvas) { if (!window.PointerEvent) { return; } canvas.addEventListener("pointerdown", (event_) => { this.debug("mouse", "pointerdown:", event_); if (event_.pointerType === "touch") { this.pointer_down = event_.pointerId; this.pointer_last_x = event_.offsetX; this.pointer_last_y = event_.offsetY; } }); canvas.addEventListener("mousemove", (event_) => { this.debug("mouse", "mousemove:", event_); if (this.pointer_down === event_.pointerId) { const dx = event_.offsetX - this.pointer_last_x; const dy = event_.offsetY - this.pointer_last_y; this.pointer_last_x = event_.offsetX; this.pointer_last_y = event_.offsetY; const mult = 20 * (window.devicePixelRatio || 1); event_.wheelDeltaX = Math.round(dx * mult); event_.wheelDeltaY = Math.round(dy * mult); return this.mouse_scroll_cb(event_, this); } }); canvas.addEventListener("pointerup", (event_) => { this.debug("mouse", "pointerup:", event_); this.pointer_down = -1; }); canvas.addEventListener("pointercancel", (event_) => { this.debug("mouse", "pointercancel:", event_); this.pointer_down = -1; }); canvas.addEventListener("pointerout", (event_) => { this.debug("mouse", "pointerout:", event_); }); //wheel events on a window: const me = this; function on_mousescroll(e) { me.mouse_scroll_cb(e, me); e.stopPropagation(); return e.preventDefault(); } if (Utilities.isEventSupported("wheel")) { canvas.addEventListener("wheel", on_mousescroll, false); } } set_spinner(state) { if (state) { this.spinnerdiv.hide(); } else { this.spinnerdiv.css("display", "table"); } } ensure_visible() { if (this.client.server_is_desktop || this.client.server_is_shadow) { //those windows should usually be centered on screen, //moving them would mess that up return true; } if (this.override_redirect) { //OR windows cannot be moved server-side return true; } const oldx = this.x; const oldy = this.y; // for now make sure we don't out of top left // this will be much smarter! const min_w_visible = Math.min(80, this.w); const min_h_visible = Math.min(80, this.h); const desktop_size = this.client._get_desktop_size(); const ww = desktop_size[0]; const wh = desktop_size[1]; this.debug("geometry", "ensure_visible() min_w_visible=", min_w_visible, "min_h_visible=", min_h_visible, "desktop_size=", desktop_size); if (oldx < this.leftoffset && oldx + this.w <= min_w_visible) { this.x = min_w_visible - this.w + this.leftoffset; } else if (oldx >= ww - min_w_visible) { this.x = Math.min(oldx, ww - min_w_visible); } if (oldy <= this.topoffset && oldy <= min_h_visible) { this.y = this.topoffset; } else if (oldy >= wh - min_h_visible) { this.y = Math.min(oldy, wh - min_h_visible); } this.debug("geometry", "ensure_visible() oldx=", oldx, "oldy=", oldy, "x=", this.x, "y=", this.y); if (oldx !== this.x || oldy !== this.y) { this.updateCSSGeometry(); return false; } return true; } updateCanvasGeometry() { if (this.client.offscreen_api && this.client.decode_worker) { this.client.decode_worker.postMessage({ cmd: "canvas-geo", wid: this.wid, w: this.w, h: this.h, }); return; } // set size of both canvas if needed if (this.canvas.width !== this.w) { this.canvas.width = this.w; } if (this.canvas.height !== this.h) { this.canvas.height = this.h; } if (this.offscreen_canvas.width !== this.w) { this.offscreen_canvas.width = this.w; } if (this.offscreen_canvas.height !== this.h) { this.offscreen_canvas.height = this.h; } } updateCSSGeometry() { // set size of canvas this.updateCanvasGeometry(); if (this.client.server_is_desktop || this.client.server_is_shadow) { if (this.client.server_resize_exact) { jQuery(this.div).css("top", 0); return; } } // work out outer size this.outerH = this.h + this.topoffset + this.bottomoffset; this.outerW = this.w + this.leftoffset + this.rightoffset; // set width and height jQuery(this.div).css("width", this.outerW); jQuery(this.div).css("height", this.outerH); // set CSS attributes to outerX and outerY this.outerX = this.x - this.leftoffset; this.outerY = this.y - this.topoffset; jQuery(this.div).css("left", this.outerX); jQuery(this.div).css("top", this.outerY); this.debug("geometry", "updateCSSGeometry() left=", this.outerX, ", top=", this.outerY, ", width=", this.outerW, ", height=", this.outerH); } focus() { this.set_focus_cb(this); } updateFocus() { if (this.focused) { // set focused style to div jQuery(this.div).addClass("windowinfocus"); // Update window title if (this.client.session_name) { jQuery("title").text(client.session_name); } else { jQuery("title").text( `${location.pathname.replaceAll("/", "")}: ${this.title}` ); } // Update the icon if (this.icon !== null) { const source = this.update_icon(this.icon.width, this.icon.height, this.icon.encoding, this.icon.img_data); jQuery("#favicon").attr("href", source); } else { jQuery("#favicon").attr("href", "favicon.png"); } } else { // set not in focus style jQuery(this.div).removeClass("windowinfocus"); } } suspend() { //perhaps we should suspend updates? } resume() { this.init_canvas(); } /** * toString allows us to identify windows by their unique window id. */ toString() { return `Window(${this.wid})`; } update_zindex() { let z = 5000 + this.stacking_layer; if (this.tray) { z = 0; } else if (this.override_redirect || this.client.server_is_desktop || this.client.server_is_shadow) { z = 30_000; } else if (this.has_windowtype(["DROPDOWN", "TOOLTIP", "POPUP_MENU", "MENU", "COMBO"])) { z = 20_000; } else if (this.has_windowtype(["UTILITY", "DIALOG"])) { z = 15_000; } const above = this.metadata["above"]; if (above) { z += 5000; } else { const below = this.metadata["below"]; if (below) { z -= 5000; } } if (this.focused) { z += 2500; } jQuery(this.div).css("z-index", z); } /** * Update our metadata cache with new key-values, * then call set_metadata with these new key-values. */ update_metadata(metadata, safe) { //update our metadata cache with new key-values: this.debug("main", "update_metadata(", metadata, ")"); for (const attrname in metadata) { this.metadata[attrname] = metadata[attrname]; } if (safe) { this.set_metadata_safe(metadata); } else { this.set_metadata(metadata); } this.update_zindex(); } /** * Apply only metadata settings that are safe before window is drawn */ set_metadata_safe(metadata) { if ("title" in metadata) { let title = Utilities.s(metadata["title"]); if (this.title !== title) { this.title = title; this.log("title=", this.title); jQuery(`#title${this.wid}`).html(this.title); const trimmedTitle = Utilities.trimString(this.title, 30); jQuery(`#windowlistitemtitle${this.wid}`).text(trimmedTitle); } } if ("has-alpha" in metadata) { this.has_alpha = Boolean(metadata["has-alpha"]); } if ("window-type" in metadata) { this.windowtype = metadata["window-type"]; } if ("opacity" in metadata) { let opacity = metadata["opacity"]; opacity = opacity < 0 ? 1 : opacity / 0x1_00_00_00_00; jQuery(this.div).css("opacity", `${opacity}`); } if ("iconic" in metadata) { this.set_minimized(Boolean(metadata["iconic"])); } //if the attribute is set, add the corresponding css class: const attributes = ["modal", "above", "below"]; for (const attribute of attributes) { if (attribute in metadata) { const value = metadata[attribute]; if (value) { jQuery(this.div).addClass(attribute); } else { jQuery(this.div).removeClass(attribute); } } } if (this.resizable && "size-constraints" in metadata) { this.apply_size_constraints(); } if ("class-instance" in metadata) { const wm_class = metadata["class-instance"]; const classes = jQuery(this.div).prop("classList"); if (classes) { //remove any existing "wmclass-" classes not in the new wm_class list: for (const class_ of classes) { const tclass = `${class_}`; if (tclass.indexOf("wmclass-") === 0 && wm_class && !wm_class.includes(tclass)) { jQuery(this.div).removeClass(tclass); } } } if (wm_class) { //add new wm-class: for (const element of wm_class) { const tclass = Utilities.s(element).replace(/[^\dA-Za-z]/g, ""); if (tclass && !jQuery(this.div).hasClass(tclass)) { jQuery(this.div).addClass(`wmclass-${tclass}`); } } } } } apply_size_constraints() { if (!this.resizable) { return; } if (this.maximized) { jQuery(this.div).draggable("disable"); } else { jQuery(this.div).draggable("enable"); } let hdec = 0; const wdec = 0; if (this.decorated) { //adjust for header hdec = jQuery(`#head${this.wid}`).outerHeight(true); } let min_size = null; let max_size = null; const size_constraints = this.metadata["size-constraints"]; if (size_constraints) { min_size = size_constraints["minimum-size"]; max_size = size_constraints["maximum-size"]; } let minw = 0; let minh = 0; if (min_size) { minw = min_size[0] + wdec; minh = min_size[1] + hdec; } let maxw = null; let maxh = null; if (max_size) { maxw = max_size[0] + wdec; maxh = max_size[1] + hdec; } if (minw > 0 && minw === maxw && minh > 0 && minh === maxh) { jQuery(this.d_maximizebtn).hide(); jQuery(`#windowlistitemmax${this.wid}`).hide(); jQuery(this.div).resizable("disable"); } else { jQuery(this.d_maximizebtn).show(); if (!this.maximized) { jQuery(this.div).resizable("enable"); } else { jQuery(this.div).resizable("disable"); } } if (!this.maximized) { if (minw) { jQuery(this.div).resizable("option", "minWidth", minw); } if (minh) { jQuery(this.div).resizable("option", "minHeight", minh); } if (maxw) { jQuery(this.div).resizable("option", "maxWidth", maxw); } if (maxh) { jQuery(this.div).resizable("option", "maxHeight", maxh); } } //TODO: aspectRatio, grid } /** * Apply new metadata settings. */ set_metadata(metadata) { this.set_metadata_safe(metadata); if ("fullscreen" in metadata) { this.set_fullscreen(Boolean(metadata["fullscreen"])); } if ("maximized" in metadata) { this.set_maximized(Boolean(metadata["maximized"])); } if ("decorations" in metadata) { this.decorations = Boolean(metadata["decorations"]); this._set_decorated(this.decorations); this.updateCSSGeometry(); this.handle_resized(); this.apply_size_constraints(); } } /** * Save the window geometry so we can restore it later * (ie: when un-maximizing or un-fullscreening) */ save_geometry() { this.saved_geometry = { x: this.x, y: this.y, w: this.w, h: this.h, }; this.debug("geometry", "save_geometry() saved-geometry=", this.saved_geometry); } /** * Restores the saved geometry (if it exists). */ restore_geometry() { if (!this.saved_geometry) { return; } this.x = this.saved_geometry["x"]; this.y = this.saved_geometry["y"]; this.w = this.saved_geometry["w"]; this.h = this.saved_geometry["h"]; this.debug("geometry", "restore_geometry() saved-geometry=", this.saved_geometry); // delete saved geometry this.saved_geometry = null; // then call local resized callback this.handle_resized(); this.focus(); } /** * Maximize / unmaximizes the window. */ set_maximized(maximized) { if (jQuery(this.div).is(":hidden")) { jQuery(this.div).show(); } if (this.maximized === maximized) { return; } this.max_save_restore(maximized); this.maximized = maximized; this.handle_resized(); this.focus(); // this will take care of disabling the "draggable" code: this.apply_size_constraints(); } /** * Toggle maximized state */ toggle_maximized() { this.set_maximized(!this.maximized); } /** * Minimizes / unminimizes the window. */ set_minimized(minimized) { if (this.minimized === minimized) { return; } this.minimized = minimized; if (minimized) { jQuery(this.div).hide(200); } else { jQuery(this.div).show(200); } } /** * Toggle minimized state */ toggle_minimized() { //get the geometry before modifying the window: const geom = this.get_internal_geometry(); this.set_minimized(!this.minimized); if (this.minimized) { this.client.send([PACKET_TYPES.unmap_window, this.wid, true]); this.stacking_layer = 0; if (this.client.focused_wid === this.wid) { this.client.auto_focus(); } } else { this.client.send([PACKET_TYPES.map_window, this.wid, geom.x, geom.y, geom.w, geom.h, this.client_properties]); //ugly force focus switch: this.client.focused_wid = 0; this.client.set_focus(this); } } /** * Fullscreen / unfullscreen the window. */ set_fullscreen(fullscreen) { //the browser itself: //we can't bring attention to the fullscreen widget, //ie: $("#fullscreen").fadeIn(100).fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100); //because the window is about to cover the top bar... //so just fullscreen the window: if (this.fullscreen === fullscreen) { return; } if (this.resizable) { if (fullscreen) { this._set_decorated(false); } else { this._set_decorated(this.decorations); } } this.max_save_restore(fullscreen); this.fullscreen = fullscreen; this.updateCSSGeometry(); this.handle_resized(); this.focus(); } _set_decorated(decorated) { this.decorated = decorated; const head = document.getElementById("head"+this.wid); if (decorated) { head.style.display = 'block'; jQuery(this.div).removeClass("undecorated"); jQuery(this.div).addClass("window"); } else { head.style.display = 'none'; jQuery(this.div).removeClass("window"); jQuery(this.div).addClass("undecorated"); } this.update_offsets(); } /** * Either: * - save the geometry and use all the space * - or restore the geometry */ max_save_restore(use_all_space) { if (use_all_space) { this.save_geometry(); this.fill_screen(); } else { this.restore_geometry(); } } /** * Use up all the available screen space */ fill_screen() { // should be as simple as this // in future we may have a taskbar for minimized windows // which should be subtracted from screen size const screen_size = this.client._get_desktop_size(); this.x = this.leftoffset; this.y = this.topoffset; this.w = screen_size[0] - this.leftoffset - this.rightoffset; this.h = screen_size[1] - this.topoffset - this.bottomoffset - TASKBAR_HEIGHT; this.debug("geometry", "fill_screen() ", this.x, this.y, this.w, this.h); } /** * We have resized the window, so we need to: * - work out new position of internal canvas * - update external CSS position * - resize the backing image * - fire the geometry_cb */ handle_resized(e) { // this function is called on local resize only, // remote resize will call this.resize() // need to update the internal geometry this.debug("geometry", "handle_resized(", e, ")"); if (e) { this.x = this.x + Math.round(e.position.left - e.originalPosition.left); this.y = this.y + Math.round(e.position.top - e.originalPosition.top); this.w = Math.round(e.size.width) - this.leftoffset - this.rightoffset; this.h = Math.round(e.size.height) - this.topoffset - this.bottomoffset; } // then update CSS and redraw backing this.updateCSSGeometry(); // send geometry callback this.geometry_cb(this); } /** * Like handle_resized, except we should * store internal geometry, external is always in CSS left and top */ handle_moved(e) { const left = Math.round(e.position.left); const top = Math.round(e.position.top); this.debug("geometry", "handle_moved(", e, ") left=", left, ", top=", top); // add on padding to the event position so that // it reflects the internal geometry of the canvas this.x = left + this.leftoffset; this.y = top + this.topoffset; // make sure we are visible after move this.ensure_visible(); // tell remote we have moved window this.geometry_cb(this); } /** * The "screen" has been resized, we may need to resize our window to match * if it is fullscreen or maximized. */ screen_resized() { this.debug("geometry", "screen_resized() server_is_desktop=", this.client.server_is_desktop, ", server_is_shadow=", this.client.server_is_shadow); if (this.client.server_is_desktop) { this.match_screen_size(); } if (this.client.server_is_shadow) { //note: when this window is created, // it may not have been added to the client's list yet const ids = Object.keys(this.client.id_to_window); if (ids.length === 0 || ids[0] === this.wid) { //single window, recenter it: this.recenter(); } } if (this.fullscreen || this.maximized) { this.fill_screen(); this.handle_resized(); } if (!this.ensure_visible()) { this.geometry_cb(this); } } recenter(force_update_geometry) { let x = this.x; let y = this.y; this.debug("geometry", "recenter() x=", x, ", y=", y, ", desktop size: ", this.client.desktop_width, this.client.desktop_height); x = Math.round((this.client.desktop_width - this.w) / 2); y = Math.round((this.client.desktop_height - this.h) / 2); if (this.x !== x || this.y !== y || force_update_geometry) { this.debug("geometry", "window re-centered to:", x, y); this.x = x; this.y = y; this.updateCSSGeometry(); this.geometry_cb(this); } else { this.debug("geometry", "recenter() unchanged at ", x, y); } if (this.x < 0 || this.y < 0) { this.warn("window does not fit in canvas, offsets: ", x, y); } } match_screen_size() { const maxw = this.client.desktop_width; const maxh = this.client.desktop_height; let neww = 0; let newh = 0; if (this.client.server_resize_exact) { neww = maxw; newh = maxh; this.log("resizing to exact size:", neww, newh); } else { if (this.client.server_screen_sizes.length === 0) { this.recenter(); return; } //try to find the best screen size to use, //cannot be larger than the browser area let best = 0; let w = 0; let h = 0; const screen_sizes = this.client.server_screen_sizes; for (const screen_size of screen_sizes) { w = screen_size[0]; h = screen_size[1]; if (w <= maxw && h <= maxh && w * h > best) { best = w * h; neww = w; newh = h; } } if (neww === 0 && newh === 0) { //not found, try to find the smallest one: best = 0; for (const screen_size of screen_sizes) { w = screen_size[0]; h = screen_size[1]; if (best === 0 || w * h < best) { best = w * h; neww = w; newh = h; } } } this.log("best screen size:", neww, newh); } this.w = neww; this.h = newh; this.recenter(true); } /** * Things ported from original shape */ move_resize(x, y, w, h) { this.debug("geometry", "move_resize(", x, y, w, h, ")"); // only do it if actually changed! if (this.w !== w || this.h !== h || this.x !== x || this.y !== y) { this.w = w; this.h = h; this.x = x; this.y = y; if (!this.ensure_visible()) { // we had to move the window so that it was visible // is this the right thing to do? this.geometry_cb(this); } else { this.updateCSSGeometry(); } } } move(x, y) { this.debug("geometry", "move(", x, y, ")"); this.move_resize(x, y, this.w, this.h); } resize(w, h) { this.debug("geometry", "resize(", w, h, ")"); this.move_resize(this.x, this.y, w, h); } initiate_moveresize(mousedown_event, x_root, y_root, direction, button, source_indication) { const dir_str = MOVERESIZE_DIRECTION_STRING[direction]; this.log("initiate_moveresize", dir_str, [x_root, y_root, direction, button, source_indication]); if (direction === MOVERESIZE_MOVE && mousedown_event) { const e = mousedown_event; e.type = "mousedown.draggable"; e.target = this.div[0]; jQuery(this.div).trigger(e); } else if (direction === MOVERESIZE_CANCEL) { jQuery(this.div).draggable("disable"); jQuery(this.div).draggable("enable"); } else if (direction in MOVERESIZE_DIRECTION_JS_NAME) { const js_dir = MOVERESIZE_DIRECTION_JS_NAME[direction]; const resize_widget = jQuery(this.div) .find(`.ui-resizable-handle.ui-resizable-${js_dir}`) .first(); if (resize_widget) { const pageX = resize_widget.offset().left; const pageY = resize_widget.offset().top; resize_widget.trigger("mouseover"); resize_widget.trigger({ type: "mousedown", which: 1, pageX, pageY, }); } } } /** * Returns the geometry of the window backing image, * the inner window geometry (without any borders or top bar). */ get_internal_geometry() { /* we store the internal geometry only * and work out external geometry on the fly whilst * updating CSS */ return { x: this.x, y: this.y, w: this.w, h: this.h, }; } update_icon(width, height, encoding, img_data) { // Cache the icon. this.icon = { width, height, encoding, img_data, }; let source = "favicon.png"; if (encoding === "png") { //move title to the right: $(`#title${this.wid}`).css("left", 32); if (typeof img_data === "string") { const uint = new Uint8Array(img_data.length); for (let index = 0; index < img_data.length; ++index) { uint[index] = img_data.charCodeAt(index); } img_data = uint; } source = this.construct_base64_image_url(encoding, img_data); } jQuery(`#windowicon${this.wid}`).attr("src", source); jQuery(`#windowlistitemicon${this.wid}`).attr("src", source); return source; } reset_cursor() { jQuery(`#${this.wid}`).css("cursor", "default"); this.cursor_data = null; } set_cursor(encoding, w, h, xhot, yhot, img_data) { if (encoding !== "png") { this.warn("received an invalid cursor encoding:", encoding); return; } const window_element = jQuery(`#${this.wid}`); const cursor_url = this.construct_base64_image_url(encoding, img_data); const me = this; function set_cursor_url(url, x, y, w, h) { const url_string = `url('${url}')`; window_element.css("cursor", `${url_string}, default`); //CSS3 with hotspot: window_element.css("cursor", `${url_string} ${x} ${y}, auto`); me.cursor_data = [url, x, y, w, h]; } let zoom = detectZoom.zoom(); //prefer fractional zoom values if possible: if (Math.round(zoom * 4) === 2 * Math.round(zoom * 2)) { zoom = Math.round(zoom * 2) / 2; } if (zoom !== 1 && !Utilities.isMacOS()) { //scale it: this.debug("geometry", "scaling cursor by zoom factor:", zoom); const temporary_img = new Image(); temporary_img.addEventListener("load", () => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); context.imageSmoothingEnabled = false; canvas.width = Math.round(w * window.devicePixelRatio); canvas.height = Math.round(h * window.devicePixelRatio); context.drawImage(temporary_img, 0, 0, canvas.width, canvas.height); const scaled_cursor_url = canvas.toDataURL(); set_cursor_url( scaled_cursor_url, Math.round(xhot * window.devicePixelRatio), Math.round(yhot * window.devicePixelRatio), Math.round(canvas.width), Math.round(canvas.height), ); }); temporary_img.src = cursor_url; } else { set_cursor_url(cursor_url, xhot, yhot, w, h); } } eos() { //we don't handle video streams in this class, //so this should never be called } /** * This function draws the contents of the off-screen canvas to the visible * canvas. However the drawing is requested by requestAnimationFrame which allows * the browser to group screen redraws together, and automatically adjusts the * framerate e.g if the browser window/tab is not visible. */ draw() { //pass the 'buffer' canvas directly to visible canvas context if (this.has_alpha || this.tray) { this.canvas_ctx.clearRect(0, 0, this.draw_canvas.width, this.draw_canvas.height); } this.canvas_ctx.drawImage(this.draw_canvas, 0, 0); } /** * Updates the window image with new pixel data * we have received from the server. * The image is painted into off-screen canvas. */ paint() { if (this.client.decode_worker) { //no need to synchronize paint packets here //the decode worker ensures that we get the packets //in the correct order, ready to update the canvas Reflect.apply(this.do_paint, this, arguments); return; } //process all paint request in order using the paint_queue: const item = Array.prototype.slice.call(arguments); this.paint_queue.push(item); this.may_paint_now(); } /** * Pick items from the paint_queue * if we're not already in the process of painting something. */ may_paint_now() { this.debug("draw", "may_paint_now() paint pending=", this.paint_pending, ", paint queue length=", this.paint_queue.length); let now = performance.now(); while ( (this.paint_pending === 0 || now - this.paint_pending >= 2000) && this.paint_queue.length > 0 ) { this.paint_pending = now; const item = this.paint_queue.shift(); this.do_paint.apply(this, item); now = performance.now(); } } paint_box(color, px, py, pw, ph) { this.offscreen_canvas_ctx.strokeStyle = color; this.offscreen_canvas_ctx.lineWidth = 2; this.offscreen_canvas_ctx.strokeRect(px, py, pw, ph); } do_paint(packet, decode_callback) { const me = this; const x = packet[2]; const y = packet[3]; const width = packet[4]; const height = packet[5]; const img_data = packet[7]; const options = packet[10] || {}; let coding = Utilities.s(packet[6]); let enc_width = width; let enc_height = height; const scaled_size = options["scaled_size"]; if (scaled_size) { enc_width = scaled_size[0]; enc_height = scaled_size[1]; } const bitmap = coding.startsWith("bitmap:"); if (bitmap) { coding = coding.split(":")[1]; this.debug("draw", coding, img_data, " at ", `${x},${y}`, ") focused=", this.focused); } else { this.debug("draw", "do_paint(", img_data.length, " bytes of", coding, " data ", width, "x", height, " at ", x, ",", y, ") focused=", this.focused); } function painted(skip_box) { me.paint_pending = 0; if (!skip_box && me.debug_categories.includes("draw")) { const color = DEFAULT_BOX_COLORS[coding] || "white"; me.paint_box(color, x, y, width, height); } decode_callback(); } function paint_error(e) { me.error("error painting", coding, e); me.paint_pending = 0; decode_callback(`${e}`); } function paint_bitmap() { //the decode worker is giving us a Bitmap object ready to use: me.offscreen_canvas_ctx.clearRect(x, y, img_data.width, img_data.height); me.offscreen_canvas_ctx.drawImage(img_data, x, y); painted(); //this isn't really needed since we don't use the paint_queue at all //when decoding in the worker (bitmaps can only come from the decode worker) me.may_paint_now(); } try { if (coding === "void") { painted(true); this.may_paint_now(); } else if (coding === "rgb32" || coding === "rgb24") { if (bitmap) { paint_bitmap(); return; } const rgb_data = decode_rgb(packet); const img = this.offscreen_canvas_ctx.createImageData(enc_width, enc_height); img.data.set(rgb_data); this.offscreen_canvas_ctx.putImageData(img, x, y, 0, 0, width, height); painted(); this.may_paint_now(); } else if (coding === "jpeg" || coding.startsWith("png") || coding === "webp") { if (bitmap) { paint_bitmap(); return; } const image = new Image(); image.addEventListener("load", () => { if (image.width === 0 || image.height === 0) { paint_error(`invalid image size: ${image.width}x${image.height}`); } else { this.offscreen_canvas_ctx.clearRect(x, y, width, height); this.offscreen_canvas_ctx.drawImage(image, x, y, width, height); painted(); } this.may_paint_now(); }); image.onerror = () => { paint_error(`failed to load ${coding} into image tag`); this.may_paint_now(); }; const paint_coding = coding.split("/")[0]; //ie: "png/P" -> "png" image.src = this.construct_base64_image_url(paint_coding, img_data); } else if (coding === "h264") { paint_error("h264 decoding is only supported via the decode workers"); this.may_paint_now(); } else if (coding === "scroll") { // newer servers use options, // older ones overload the image data: const scrolls = options["scroll"] || img_data; for (let index = 0, stop = scrolls.length; index < stop; ++index) { const scroll_data = scrolls[index]; this.debug("draw", "scroll", index, ":", scroll_data); const sx = scroll_data[0]; const sy = scroll_data[1]; const sw = scroll_data[2]; const sh = scroll_data[3]; const xdelta = scroll_data[4]; const ydelta = scroll_data[5]; this.offscreen_canvas_ctx.drawImage( this.draw_canvas, sx, sy, sw, sh, sx + xdelta, sy + ydelta, sw, sh ); if (this.debug_categories.includes("draw")) { this.paint_box("brown", sx + xdelta, sy + ydelta, sw, sh); } } painted(true); this.may_paint_now(); } else { paint_error("unsupported encoding"); } } catch (error) { const packet_sequence = packet[8]; this.exc(error, "error painting", coding, "sequence no", packet_sequence); paint_error(error); } } construct_base64_image_url(encoding, imageDataArrayBuffer) { const imageDataBase64 = Utilities.ArrayBufferToBase64(imageDataArrayBuffer); return `data:image/${encoding};base64,${imageDataBase64}`; } /** * Close the window and free all resources */ destroy() { // remove div this.div.remove(); } }