diff --git a/app/ui.js b/app/ui.js index ed8530a7c..22e4ffc22 100644 --- a/app/ui.js +++ b/app/ui.js @@ -9,7 +9,7 @@ import * as Log from '../core/util/logging.js'; import _, { l10n } from './localization.js'; import { isTouchDevice, isMac, isIOS, isAndroid, isChromeOS, isSafari, - hasScrollbarGutter, dragThreshold } + hasScrollbarGutter, dragThreshold, browserAsyncClipboardSupport } from '../core/util/browser.js'; import { setCapture, getPointerEvent } from '../core/util/events.js'; import KeyTable from "../core/input/keysym.js"; @@ -20,7 +20,7 @@ import * as WebUtil from "./webutil.js"; const PAGE_TITLE = "noVNC"; -const LINGUAS = ["cs", "de", "el", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt_BR", "ru", "sv", "tr", "zh_CN", "zh_TW"]; +const LINGUAS = ["cs", "de", "el", "es", "fr", "hr", "it", "ja", "ko", "nl", "pl", "pt_BR", "ru", "sv", "tr", "zh_CN", "zh_TW"]; const UI = { @@ -1160,6 +1160,7 @@ const UI = { UI.rfb.showDotCursor = UI.getSetting('show_dot'); UI.updateViewOnly(); // requires UI.rfb + UI.updateClipboard(); }, disconnect() { @@ -1817,6 +1818,31 @@ const UI = { } }, + updateClipboard() { + browserAsyncClipboardSupport() + .then((support) => { + if (support === 'unsupported') { + // Use fallback clipboard panel + return; + } + if (support === 'denied' || support === 'available') { + UI.closeClipboardPanel(); + document.getElementById('noVNC_clipboard_button') + .classList.add('noVNC_hidden'); + document.getElementById('noVNC_clipboard_button') + .removeEventListener('click', UI.toggleClipboardPanel); + document.getElementById('noVNC_clipboard_text') + .removeEventListener('change', UI.clipboardSend); + if (UI.rfb) { + UI.rfb.removeEventListener('clipboard', UI.clipboardReceive); + } + } + }) + .catch(() => { + // Treat as unsupported + }); + }, + updateShowDotCursor() { if (!UI.rfb) return; UI.rfb.showDotCursor = UI.getSetting('show_dot'); diff --git a/core/clipboard.js b/core/clipboard.js new file mode 100644 index 000000000..ae3cad155 --- /dev/null +++ b/core/clipboard.js @@ -0,0 +1,72 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (c) 2025 The noVNC authors + * Licensed under MPL 2.0 or any later version (see LICENSE.txt) + */ + +import * as Log from './util/logging.js'; +import { browserAsyncClipboardSupport } from './util/browser.js'; + +export default class AsyncClipboard { + constructor(target) { + this._target = target || null; + + this._isAvailable = null; + + this._eventHandlers = { + 'focus': this._handleFocus.bind(this), + }; + + // ===== EVENT HANDLERS ===== + + this.onpaste = () => {}; + } + + // ===== PRIVATE METHODS ===== + + async _ensureAvailable() { + if (this._isAvailable !== null) return this._isAvailable; + try { + const status = await browserAsyncClipboardSupport(); + this._isAvailable = (status === 'available'); + } catch { + this._isAvailable = false; + } + return this._isAvailable; + } + + async _handleFocus(event) { + if (!(await this._ensureAvailable())) return; + try { + const text = await navigator.clipboard.readText(); + this.onpaste(text); + } catch (error) { + Log.Error("Clipboard read failed: ", error); + } + } + + // ===== PUBLIC METHODS ===== + + writeClipboard(text) { + // Can lazily check cached availability + if (!this._isAvailable) return false; + navigator.clipboard.writeText(text) + .catch(error => Log.Error("Clipboard write failed: ", error)); + return true; + } + + grab() { + if (!this._target) return; + this._ensureAvailable() + .then((isAvailable) => { + if (isAvailable) { + this._target.addEventListener('focus', this._eventHandlers.focus); + } + }); + } + + ungrab() { + if (!this._target) return; + this._target.removeEventListener('focus', this._eventHandlers.focus); + } +} diff --git a/core/decoders/jpeg.js b/core/decoders/jpeg.js index 5fd1e0564..f79846034 100644 --- a/core/decoders/jpeg.js +++ b/core/decoders/jpeg.js @@ -119,18 +119,33 @@ export default class JPEGDecoder { let extra = 0; if (type === 0xDA) { // start of scan - extra += 2; + if (sock.rQwait("JPEG", length-2 + 2, 4)) { + return null; + } + + let len = sock.rQlen(); + let data = sock.rQpeekBytes(len, false); + while (true) { - if (sock.rQwait("JPEG", length-2+extra, 4)) { + let idx = data.indexOf(0xFF, length-2+extra); + if (idx === -1) { + sock.rQwait("JPEG", Infinity, 4); return null; } - let data = sock.rQpeekBytes(length-2+extra, false); - if (data.at(-2) === 0xFF && data.at(-1) !== 0x00 && - !(data.at(-1) >= 0xD0 && data.at(-1) <= 0xD7)) { - extra -= 2; - break; + + if (idx === len-1) { + sock.rQwait("JPEG", Infinity, 4); + return null; } - extra++; + + if (data.at(idx+1) === 0x00 || + (data.at(idx+1) >= 0xD0 && data.at(idx+1) <= 0xD7)) { + extra = idx+2 - (length-2); + continue; + } + + extra = idx - (length-2); + break; } } diff --git a/core/display.js b/core/display.js index ef42ac666..4efd6f4b9 100644 --- a/core/display.js +++ b/core/display.js @@ -521,6 +521,9 @@ export default class Display { return; } this.drawImage(a.img, a.x, a.y); + // This helps the browser free the memory right + // away, rather than ballooning + a.img.src = ""; } else { a.img._noVNCDisplay = this; a.img.addEventListener('load', this._resumeRenderQ); diff --git a/core/rfb.js b/core/rfb.js index a10932405..7f0f6b9a6 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -15,6 +15,7 @@ import { clientToElement } from './util/element.js'; import { setCapture } from './util/events.js'; import EventTargetMixin from './util/eventtarget.js'; import Display from "./display.js"; +import AsyncClipboard from "./clipboard.js"; import Inflator from "./inflator.js"; import Deflator from "./deflator.js"; import Keyboard from "./input/keyboard.js"; @@ -164,6 +165,7 @@ export default class RFB extends EventTargetMixin { this._sock = null; // Websock object this._display = null; // Display object this._flushing = false; // Display flushing state + this._asyncClipboard = null; // Async clipboard object this._keyboard = null; // Keyboard input handler object this._gestures = null; // Gesture input handler object this._resizeObserver = null; // Resize observer object @@ -266,6 +268,9 @@ export default class RFB extends EventTargetMixin { throw exc; } + this._asyncClipboard = new AsyncClipboard(this._canvas); + this._asyncClipboard.onpaste = this.clipboardPasteFrom.bind(this); + this._keyboard = new Keyboard(this._canvas); this._keyboard.onkeyevent = this._handleKeyEvent.bind(this); this._remoteCapsLock = null; // Null indicates unknown or irrelevant @@ -317,8 +322,10 @@ export default class RFB extends EventTargetMixin { this._rfbConnectionState === "connected") { if (viewOnly) { this._keyboard.ungrab(); + this._asyncClipboard.ungrab(); } else { this._keyboard.grab(); + this._asyncClipboard.grab(); } } } @@ -2221,7 +2228,10 @@ export default class RFB extends EventTargetMixin { this._setDesktopName(name); this._resize(width, height); - if (!this._viewOnly) { this._keyboard.grab(); } + if (!this._viewOnly) { + this._keyboard.grab(); + this._asyncClipboard.grab(); + } this._fbDepth = 24; @@ -2336,6 +2346,15 @@ export default class RFB extends EventTargetMixin { return this._fail("Unexpected SetColorMapEntries message"); } + _writeClipboard(text) { + if (this._viewOnly) return; + if (this._asyncClipboard.writeClipboard(text)) return; + // Fallback clipboard + this.dispatchEvent( + new CustomEvent("clipboard", {detail: {text: text}}) + ); + } + _handleServerCutText() { Log.Debug("ServerCutText"); @@ -2355,9 +2374,7 @@ export default class RFB extends EventTargetMixin { return true; } - this.dispatchEvent(new CustomEvent( - "clipboard", - { detail: { text: text } })); + this._writeClipboard(text); } else { //Extended msg. @@ -2493,9 +2510,7 @@ export default class RFB extends EventTargetMixin { textData = textData.replaceAll("\r\n", "\n"); - this.dispatchEvent(new CustomEvent( - "clipboard", - { detail: { text: textData } })); + this._writeClipboard(textData); } } else { return this._fail("Unexpected action in extended clipboard message: " + actions); diff --git a/core/util/browser.js b/core/util/browser.js index 63596d211..12f47a76a 100644 --- a/core/util/browser.js +++ b/core/util/browser.js @@ -11,6 +11,39 @@ import * as Log from './logging.js'; import Base64 from '../base64.js'; +// Async clipboard detection + +/* Evaluates if there is browser support for the async clipboard API and + * relevant clipboard permissions. Returns 'unsupported' if permission states + * cannot be resolved. On the other hand, detecting 'granted' or 'prompt' + * permission states for both read and write indicates full API support with no + * imposed native browser paste prompt. Conversely, detecting 'denied' indicates + * the user elected to disable clipboard. + */ +export async function browserAsyncClipboardSupport() { + if (!(navigator?.permissions?.query && + navigator?.clipboard?.writeText && + navigator?.clipboard?.readText)) { + return 'unsupported'; + } + try { + const writePerm = await navigator.permissions.query( + {name: "clipboard-write", allowWithoutGesture: true}); + const readPerm = await navigator.permissions.query( + {name: "clipboard-read", allowWithoutGesture: false}); + if (writePerm.state === "denied" || readPerm.state === "denied") { + return 'denied'; + } + if ((writePerm.state === "granted" || writePerm.state === "prompt") && + (readPerm.state === "granted" || readPerm.state === "prompt")) { + return 'available'; + } + } catch { + return 'unsupported'; + } + return 'unsupported'; +} + // Touch detection export let isTouchDevice = ('ontouchstart' in document.documentElement) || // required for Chrome debugger diff --git a/core/websock.js b/core/websock.js index ae17a4409..ee8a4bc42 100644 --- a/core/websock.js +++ b/core/websock.js @@ -124,6 +124,10 @@ export default class Websock { return res >>> 0; } + rQlen() { + return this._rQlen - this._rQi; + } + rQshiftStr(len) { let str = ""; // Handle large arrays in steps to avoid long strings on the stack diff --git a/docs/API-internal.md b/docs/API-internal.md index 5b41548ed..1d0a1104f 100644 --- a/docs/API-internal.md +++ b/docs/API-internal.md @@ -18,6 +18,8 @@ keysym values. * __Display__ (core/display.js): Efficient 2D rendering abstraction layered on the HTML5 canvas element. +* __Clipboard__ (core/clipboard.js): Clipboard event handler. + * __Websock__ (core/websock.js): Websock client from websockify with transparent binary data support. [Websock API](https://github.com/novnc/websockify-js/wiki/websock.js) wiki page. @@ -25,10 +27,10 @@ with transparent binary data support. ## 1.2 Callbacks -For the Mouse, Keyboard and Display objects the callback functions are -assigned to configuration attributes, just as for the RFB object. The -WebSock module has a method named 'on' that takes two parameters: the -callback event name, and the callback function. +For the Mouse, Keyboard, Display, and Clipboard objects, the callback +functions are assigned to configuration attributes, just as for the RFB +object. The WebSock module has a method named 'on' that takes two +parameters: the callback event name, and the callback function. ## 2. Modules @@ -81,3 +83,23 @@ None | blitImage | (x, y, width, height, arr, offset, from_queue) | Blit pixels (of R,G,B,A) to the display | drawImage | (img, x, y) | Draw image and track damage | autoscale | (containerWidth, containerHeight) | Scale the display + +## 2.3 Clipboard module + +### 2.3.1 Configuration attributes + +None + +### 2.3.2 Methods + +| name | parameters | description +| ------------------ | ----------------- | ------------ +| writeClipboard | (text) | An async write text to clipboard +| grab | () | Begin capturing clipboard events +| ungrab | () | Stop capturing clipboard events + +### 2.3.3 Callbacks + +| name | parameters | description +| ------- | ---------- | ------------ +| onpaste | (text) | Called following a target focus event and an async clipboard read diff --git a/karma.conf.js b/karma.conf.cjs similarity index 100% rename from karma.conf.js rename to karma.conf.cjs diff --git a/package.json b/package.json index bfe16a542..3e60b63e2 100644 --- a/package.json +++ b/package.json @@ -2,24 +2,20 @@ "name": "@novnc/novnc", "version": "1.6.0", "description": "An HTML5 VNC client", - "browser": "lib/rfb", - "directories": { - "lib": "lib", - "doc": "docs", - "test": "tests" - }, + "type": "module", "files": [ - "lib", + "core", + "vendor", "AUTHORS", "VERSION", "docs/API.md", "docs/LIBRARY.md", "docs/LICENSE*" ], + "exports": "./core/rfb.js", "scripts": { "lint": "eslint app core po/po2js po/xgettext-html tests utils", - "test": "karma start karma.conf.js", - "prepublish": "node ./utils/convert.js --clean" + "test": "karma start karma.conf.cjs" }, "repository": { "type": "git", diff --git a/po/Makefile b/po/Makefile index dcf5ba446..5572c9401 100644 --- a/po/Makefile +++ b/po/Makefile @@ -2,7 +2,7 @@ all: .PHONY: update-po update-js update-pot .PHONY: FORCE -LINGUAS := cs de el es fr it ja ko nl pl pt_BR ru sv tr zh_CN zh_TW +LINGUAS := cs de el es fr hr it ja ko nl pl pt_BR ru sv tr zh_CN zh_TW VERSION := $(shell grep '"version"' ../package.json | cut -d '"' -f 4) diff --git a/po/hr.po b/po/hr.po new file mode 100644 index 000000000..a2165c669 --- /dev/null +++ b/po/hr.po @@ -0,0 +1,338 @@ +# Croatian translations for noVNC package +# Hrvatski prijevod za noVNC paket +# Copyright (C) 2025 The noVNC authors +# This file is distributed under the same license as the noVNC package. +# Milo Ivir , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: noVNC 1.6.0\n" +"Report-Msgid-Bugs-To: novnc@googlegroups.com\n" +"POT-Creation-Date: 2025-02-14 10:14+0100\n" +"PO-Revision-Date: 2025-08-25 18:24+0200\n" +"Last-Translator: Milo Ivir \n" +"Language-Team: \n" +"Language: hr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.7\n" + +#: ../app/ui.js:84 +msgid "" +"Running without HTTPS is not recommended, crashes or other issues are likely." +msgstr "" +"Pokretanje bez HTTPS-a se ne preporučuje, vjerojatno će se dogoditi prekidi " +"rada ili drugi problemi." + +#: ../app/ui.js:413 +msgid "Connecting..." +msgstr "Povezivanje …" + +#: ../app/ui.js:420 +msgid "Disconnecting..." +msgstr "Odspajanje …" + +#: ../app/ui.js:426 +msgid "Reconnecting..." +msgstr "Ponovno povezivanje …" + +#: ../app/ui.js:431 +msgid "Internal error" +msgstr "Interna greška" + +#: ../app/ui.js:1079 +msgid "Failed to connect to server: " +msgstr "Povezivanje sa serverom nije uspjelo: " + +#: ../app/ui.js:1145 +msgid "Connected (encrypted) to " +msgstr "Povezano (šifrirano) na " + +#: ../app/ui.js:1147 +msgid "Connected (unencrypted) to " +msgstr "Povezano (nešifrirano) na " + +#: ../app/ui.js:1170 +msgid "Something went wrong, connection is closed" +msgstr "Nešto nije u redu, veza je zatvorena" + +#: ../app/ui.js:1173 +msgid "Failed to connect to server" +msgstr "Povezivanje sa serverom nije uspjelo" + +#: ../app/ui.js:1185 +msgid "Disconnected" +msgstr "Odspojeno" + +#: ../app/ui.js:1200 +msgid "New connection has been rejected with reason: " +msgstr "Nova veza je odbijena s razlogom: " + +#: ../app/ui.js:1203 +msgid "New connection has been rejected" +msgstr "Nova veza je odbijena" + +#: ../app/ui.js:1269 +msgid "Credentials are required" +msgstr "Podaci za prijavu su obavezni" + +#: ../vnc.html:106 +msgid "noVNC encountered an error:" +msgstr "noVNC je naišao na grešku:" + +#: ../vnc.html:116 +msgid "Hide/Show the control bar" +msgstr "Sakrij/Prikaži traku kontrola" + +#: ../vnc.html:125 +msgid "Drag" +msgstr "Povuci" + +#: ../vnc.html:125 +msgid "Move/Drag viewport" +msgstr "Pomakni/Povuci vidljivo područje" + +#: ../vnc.html:131 +msgid "Keyboard" +msgstr "Tipkovnica" + +#: ../vnc.html:131 +msgid "Show keyboard" +msgstr "Prikaži tipkovnicu" + +#: ../vnc.html:136 +msgid "Extra keys" +msgstr "Dodatne tipke" + +#: ../vnc.html:136 +msgid "Show extra keys" +msgstr "Prikaži dodatne tipke" + +#: ../vnc.html:141 +msgid "Ctrl" +msgstr "Ctrl" + +#: ../vnc.html:141 +msgid "Toggle Ctrl" +msgstr "Uključi/Isključi Ctrl" + +#: ../vnc.html:144 +msgid "Alt" +msgstr "Alt" + +#: ../vnc.html:144 +msgid "Toggle Alt" +msgstr "Uključi/Isključi Alt" + +#: ../vnc.html:147 +msgid "Toggle Windows" +msgstr "Uključi/Isključi Windows" + +#: ../vnc.html:147 +msgid "Windows" +msgstr "Windows" + +#: ../vnc.html:150 +msgid "Send Tab" +msgstr "Pošalji tabulator" + +#: ../vnc.html:150 +msgid "Tab" +msgstr "Tabulator" + +#: ../vnc.html:153 +msgid "Esc" +msgstr "Esc" + +#: ../vnc.html:153 +msgid "Send Escape" +msgstr "Pošalji Escape" + +#: ../vnc.html:156 +msgid "Ctrl+Alt+Del" +msgstr "Ctrl + Alt + Del" + +#: ../vnc.html:156 +msgid "Send Ctrl-Alt-Del" +msgstr "Pošalji Ctrl+Alt+Del" + +#: ../vnc.html:163 +msgid "Shutdown/Reboot" +msgstr "Isključi/Ponovo pokreni" + +#: ../vnc.html:163 +msgid "Shutdown/Reboot..." +msgstr "Isključi/Ponovo pokreni …" + +#: ../vnc.html:169 +msgid "Power" +msgstr "Napajanje" + +#: ../vnc.html:171 +msgid "Shutdown" +msgstr "Isključi" + +#: ../vnc.html:172 +msgid "Reboot" +msgstr "Ponovo pokreni" + +#: ../vnc.html:173 +msgid "Reset" +msgstr "Resetiraj" + +#: ../vnc.html:178 ../vnc.html:184 +msgid "Clipboard" +msgstr "Međuspremnik" + +#: ../vnc.html:186 +msgid "Edit clipboard content in the textarea below." +msgstr "Uredi sadržaj međuspremnika u donjem području teksta." + +#: ../vnc.html:194 +msgid "Full screen" +msgstr "Cjeloekranski prikaz" + +#: ../vnc.html:199 ../vnc.html:205 +msgid "Settings" +msgstr "Postavke" + +#: ../vnc.html:211 +msgid "Shared mode" +msgstr "Dijeljeni modus" + +#: ../vnc.html:218 +msgid "View only" +msgstr "Samo prikaz" + +#: ../vnc.html:226 +msgid "Clip to window" +msgstr "Isijeci na veličinu prozora" + +#: ../vnc.html:231 +msgid "Scaling mode:" +msgstr "Modus skaliranja:" + +#: ../vnc.html:233 +msgid "None" +msgstr "Bez" + +#: ../vnc.html:234 +msgid "Local scaling" +msgstr "Lokalno skaliranje" + +#: ../vnc.html:235 +msgid "Remote resizing" +msgstr "Daljinsko mijenjanje veličine" + +#: ../vnc.html:240 +msgid "Advanced" +msgstr "Napredno" + +#: ../vnc.html:243 +msgid "Quality:" +msgstr "Kvaliteta:" + +#: ../vnc.html:247 +msgid "Compression level:" +msgstr "Razina kompresije:" + +#: ../vnc.html:252 +msgid "Repeater ID:" +msgstr "ID repetitora:" + +#: ../vnc.html:256 +msgid "WebSocket" +msgstr "WebSocket" + +#: ../vnc.html:261 +msgid "Encrypt" +msgstr "Šifriraj" + +#: ../vnc.html:266 +msgid "Host:" +msgstr "Host:" + +#: ../vnc.html:270 +msgid "Port:" +msgstr "Priključak:" + +#: ../vnc.html:274 +msgid "Path:" +msgstr "Putanja:" + +#: ../vnc.html:283 +msgid "Automatic reconnect" +msgstr "Automatsko ponovno povezivanje" + +#: ../vnc.html:288 +msgid "Reconnect delay (ms):" +msgstr "Kašnjenje ponovnog povezivanja (ms):" + +#: ../vnc.html:295 +msgid "Show dot when no cursor" +msgstr "Prikaži točku kada nema pokazivača" + +#: ../vnc.html:302 +msgid "Logging:" +msgstr "Zapisivanje:" + +#: ../vnc.html:311 +msgid "Version:" +msgstr "Verzija:" + +#: ../vnc.html:319 +msgid "Disconnect" +msgstr "Odspoji" + +#: ../vnc.html:342 +msgid "Connect" +msgstr "Poveži" + +#: ../vnc.html:351 +msgid "Server identity" +msgstr "Identitet servera" + +#: ../vnc.html:354 +msgid "The server has provided the following identifying information:" +msgstr "Server je pružio sljedeće identifikacijske podatke:" + +#: ../vnc.html:357 +msgid "Fingerprint:" +msgstr "Otisak:" + +#: ../vnc.html:361 +msgid "" +"Please verify that the information is correct and press \"Approve\". " +"Otherwise press \"Reject\"." +msgstr "" +"Provjeri jesu li podaci točni i pritisni „Odobri“. U suprotnom pritisni " +"„Odbaci“." + +#: ../vnc.html:366 +msgid "Approve" +msgstr "Odobri" + +#: ../vnc.html:367 +msgid "Reject" +msgstr "Odbij" + +#: ../vnc.html:375 +msgid "Credentials" +msgstr "Podaci za prijavu" + +#: ../vnc.html:379 +msgid "Username:" +msgstr "Korisničko ime:" + +#: ../vnc.html:383 +msgid "Password:" +msgstr "Lozinka:" + +#: ../vnc.html:387 +msgid "Send credentials" +msgstr "Pošalji podatke za prijavu" + +#: ../vnc.html:396 +msgid "Cancel" +msgstr "Odustani" diff --git a/po/po2js b/po/po2js index 6347e1ea5..38effd300 100755 --- a/po/po2js +++ b/po/po2js @@ -17,9 +17,9 @@ * along with this program. If not, see . */ -const { program } = require('commander'); -const fs = require('fs'); -const pofile = require("pofile"); +import { program } from 'commander'; +import fs from 'fs'; +import pofile from "pofile"; program .argument('') diff --git a/po/xgettext-html b/po/xgettext-html index f5ba57cc5..3fcaacd43 100755 --- a/po/xgettext-html +++ b/po/xgettext-html @@ -5,9 +5,9 @@ * Licensed under MPL 2.0 (see LICENSE.txt) */ -const { program } = require('commander'); -const jsdom = require("jsdom"); -const fs = require("fs"); +import { program } from 'commander'; +import jsdom from 'jsdom'; +import fs from 'fs'; program .argument('') @@ -106,7 +106,7 @@ let output = ""; for (let str in strings) { output += "#:"; - for (location in strings[str]) { + for (let location in strings[str]) { output += " " + location; } output += "\n"; diff --git a/tests/assertions.js b/tests/assertions.js index a70122717..70c8a2c74 100644 --- a/tests/assertions.js +++ b/tests/assertions.js @@ -1,4 +1,4 @@ -import * as chai from '../node_modules/chai/chai.js'; +import * as chai from '../node_modules/chai/index.js'; import sinon from '../node_modules/sinon/pkg/sinon-esm.js'; import sinonChai from '../node_modules/sinon-chai/lib/sinon-chai.js'; diff --git a/tests/test.browser.js b/tests/test.browser.js index 692cc23b2..6c9bc568e 100644 --- a/tests/test.browser.js +++ b/tests/test.browser.js @@ -1,6 +1,74 @@ import { isMac, isWindows, isIOS, isAndroid, isChromeOS, isSafari, isFirefox, isChrome, isChromium, isOpera, isEdge, - isGecko, isWebKit, isBlink } from '../core/util/browser.js'; + isGecko, isWebKit, isBlink, + browserAsyncClipboardSupport } from '../core/util/browser.js'; + +describe('Async clipboard', function () { + "use strict"; + + beforeEach(function () { + sinon.stub(navigator, "clipboard").value({ + writeText: sinon.stub(), + readText: sinon.stub(), + }); + sinon.stub(navigator, "permissions").value({ + query: sinon.stub().resolves({ state: "granted" }) + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("queries permissions with correct parameters", async function () { + const queryStub = navigator.permissions.query; + await browserAsyncClipboardSupport(); + expect(queryStub.firstCall).to.have.been.calledWithExactly({ + name: "clipboard-write", + allowWithoutGesture: true + }); + expect(queryStub.secondCall).to.have.been.calledWithExactly({ + name: "clipboard-read", + allowWithoutGesture: false + }); + }); + + it("is available when API present and permissions granted", async function () { + navigator.permissions.query.resolves({ state: "granted" }); + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('available'); + }); + + it("is available when API present and permissions yield 'prompt'", async function () { + navigator.permissions.query.resolves({ state: "prompt" }); + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('available'); + }); + + it("is unavailable when permissions denied", async function () { + navigator.permissions.query.resolves({ state: "denied" }); + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('denied'); + }); + + it("is unavailable when permissions API fails", async function () { + navigator.permissions.query.rejects(new Error("fail")); + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('unsupported'); + }); + + it("is unavailable when write text API missing", async function () { + navigator.clipboard.writeText = undefined; + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('unsupported'); + }); + + it("is unavailable when read text API missing", async function () { + navigator.clipboard.readText = undefined; + const result = await browserAsyncClipboardSupport(); + expect(result).to.equal('unsupported'); + }); +}); describe('OS detection', function () { let origNavigator; diff --git a/tests/test.clipboard.js b/tests/test.clipboard.js new file mode 100644 index 000000000..1c173a8d7 --- /dev/null +++ b/tests/test.clipboard.js @@ -0,0 +1,121 @@ +import AsyncClipboard from '../core/clipboard.js'; + +describe('Async Clipboard', function () { + "use strict"; + + let targetMock; + let clipboard; + + beforeEach(function () { + sinon.stub(navigator, "clipboard").value({ + writeText: sinon.stub().resolves(), + readText: sinon.stub().resolves(), + }); + + sinon.stub(navigator, "permissions").value({ + query: sinon.stub(), + }); + + targetMock = document.createElement("canvas"); + clipboard = new AsyncClipboard(targetMock); + }); + + afterEach(function () { + sinon.restore(); + targetMock = null; + clipboard = null; + }); + + function stubClipboardPermissions(state) { + navigator.permissions.query + .withArgs({ name: 'clipboard-write', allowWithoutGesture: true }) + .resolves({ state: state }); + navigator.permissions.query + .withArgs({ name: 'clipboard-read', allowWithoutGesture: false }) + .resolves({ state: state }); + } + + function nextTick() { + return new Promise(resolve => setTimeout(resolve, 0)); + } + + it('grab() adds listener if permissions granted', async function () { + stubClipboardPermissions('granted'); + + const addListenerSpy = sinon.spy(targetMock, 'addEventListener'); + clipboard.grab(); + + await nextTick(); + + expect(addListenerSpy.calledWith('focus')).to.be.true; + }); + + it('grab() does not add listener if permissions denied', async function () { + stubClipboardPermissions('denied'); + + const addListenerSpy = sinon.spy(targetMock, 'addEventListener'); + clipboard.grab(); + + await nextTick(); + + expect(addListenerSpy.calledWith('focus')).to.be.false; + }); + + it('focus event triggers onpaste() if permissions granted', async function () { + stubClipboardPermissions('granted'); + + const text = 'hello clipboard world'; + navigator.clipboard.readText.resolves(text); + + const spyPromise = new Promise(resolve => clipboard.onpaste = resolve); + + clipboard.grab(); + + await nextTick(); + + targetMock.dispatchEvent(new Event('focus')); + + const res = await spyPromise; + expect(res).to.equal(text); + }); + + it('focus event does not trigger onpaste() if permissions denied', async function () { + stubClipboardPermissions('denied'); + + const text = 'should not read'; + navigator.clipboard.readText.resolves(text); + + clipboard.onpaste = sinon.spy(); + + clipboard.grab(); + + await nextTick(); + + targetMock.dispatchEvent(new Event('focus')); + + expect(clipboard.onpaste.called).to.be.false; + }); + + it('writeClipboard() calls navigator.clipboard.writeText() if permissions granted', async function () { + stubClipboardPermissions('granted'); + clipboard._isAvailable = true; + + const text = 'writing to clipboard'; + const result = clipboard.writeClipboard(text); + + expect(navigator.clipboard.writeText.calledWith(text)).to.be.true; + expect(result).to.be.true; + }); + + it('writeClipboard() does not call navigator.clipboard.writeText() if permissions denied', async function () { + stubClipboardPermissions('denied'); + clipboard._isAvailable = false; + + const text = 'should not write'; + const result = clipboard.writeClipboard(text); + + expect(navigator.clipboard.writeText.called).to.be.false; + expect(result).to.be.false; + }); + +}); diff --git a/tests/test.display.js b/tests/test.display.js index 5844ce172..528b19064 100644 --- a/tests/test.display.js +++ b/tests/test.display.js @@ -384,10 +384,11 @@ describe('Display/Canvas helper', function () { }); it('should draw an image from an image object on type "img" (if complete)', function () { + const img = { complete: true }; display.drawImage = sinon.spy(); - display._renderQPush({ type: 'img', x: 3, y: 4, img: { complete: true } }); + display._renderQPush({ type: 'img', x: 3, y: 4, img: img }); expect(display.drawImage).to.have.been.calledOnce; - expect(display.drawImage).to.have.been.calledWith({ complete: true }, 3, 4); + expect(display.drawImage).to.have.been.calledWith(img, 3, 4); }); }); }); diff --git a/tests/test.rfb.js b/tests/test.rfb.js index 2a7bbeaab..7aa54cd0c 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -3467,17 +3467,48 @@ describe('Remote Frame Buffer protocol client', function () { }); describe('Normal clipboard handling receive', function () { - it('should fire the clipboard callback with the retrieved text on ServerCutText', function () { + it('should not dispatch a clipboard event following successful async write clipboard', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(true), + }; const expectedStr = 'cheese!'; const data = [3, 0, 0, 0]; push32(data, expectedStr.length); for (let i = 0; i < expectedStr.length; i++) { data.push(expectedStr.charCodeAt(i)); } - const spy = sinon.spy(); - client.addEventListener("clipboard", spy); + + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); client._sock._websocket._receiveData(new Uint8Array(data)); - expect(spy).to.have.been.calledOnce; - expect(spy.args[0][0].detail.text).to.equal(expectedStr); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedStr + )).to.be.true; + expect(dispatchEventSpy.calledWith( + new CustomEvent("clipboard", {detail: {expectedStr: expectedStr}}) + )).to.be.false; + }); + + it('should dispatch a clipboard event following unsuccessful async write clipboard', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(false), + }; + const expectedStr = 'cheese!'; + const data = [3, 0, 0, 0]; + push32(data, expectedStr.length); + for (let i = 0; i < expectedStr.length; i++) { data.push(expectedStr.charCodeAt(i)); } + + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); + + client._sock._websocket._receiveData(new Uint8Array(data)); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedStr + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedStr: expectedStr}}) + )).to.be.true; }); }); @@ -3530,8 +3561,71 @@ describe('Remote Frame Buffer protocol client', function () { client._sock._websocket._receiveData(new Uint8Array(data)); }); + it('should not dispatch a clipboard event following successful async write clipboard', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(true), + }; + let expectedData = "Schnitzel"; + let data = [3, 0, 0, 0]; + const flags = [0x10, 0x00, 0x00, 0x01]; + + let text = encodeUTF8("Schnitzel"); + let deflatedText = deflateWithSize(text); + + // How much data we are sending. + push32(data, toUnsigned32bit(-(4 + deflatedText.length))); + + data = data.concat(flags); + data = data.concat(Array.from(deflatedText)); + + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); + + client._sock._websocket._receiveData(new Uint8Array(data)); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedData + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedData: expectedData}}) + )).to.be.false; + }); + it('should dispatch a clipboard event following unsuccessful async write clipboard', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(false), + }; + let expectedData = "Potatoes"; + let data = [3, 0, 0, 0]; + const flags = [0x10, 0x00, 0x00, 0x01]; + + let text = encodeUTF8("Potatoes"); + let deflatedText = deflateWithSize(text); + + // How much data we are sending. + push32(data, toUnsigned32bit(-(4 + deflatedText.length))); + + data = data.concat(flags); + data = data.concat(Array.from(deflatedText)); + + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); + + client._sock._websocket._receiveData(new Uint8Array(data)); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedData + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedData: expectedData}}) + )).to.be.true; + }); + describe('Handle Provide', function () { - it('should update clipboard with correct Unicode data from a Provide message', function () { + it('should update clipboard with correct Unicode data from a Provide message', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(false), + }; let expectedData = "Aå漢字!"; let data = [3, 0, 0, 0]; const flags = [0x10, 0x00, 0x00, 0x01]; @@ -3545,16 +3639,23 @@ describe('Remote Frame Buffer protocol client', function () { data = data.concat(flags); data = data.concat(Array.from(deflatedText)); - const spy = sinon.spy(); - client.addEventListener("clipboard", spy); + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); client._sock._websocket._receiveData(new Uint8Array(data)); - expect(spy).to.have.been.calledOnce; - expect(spy.args[0][0].detail.text).to.equal(expectedData); - client.removeEventListener("clipboard", spy); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedData + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedData: expectedData}}) + )).to.be.true; }); - it('should update clipboard with correct escape characters from a Provide message ', function () { + it('should update clipboard with correct escape characters from a Provide message ', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(false), + }; let expectedData = "Oh\nmy\n!"; let data = [3, 0, 0, 0]; const flags = [0x10, 0x00, 0x00, 0x01]; @@ -3569,16 +3670,23 @@ describe('Remote Frame Buffer protocol client', function () { data = data.concat(flags); data = data.concat(Array.from(deflatedText)); - const spy = sinon.spy(); - client.addEventListener("clipboard", spy); + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); client._sock._websocket._receiveData(new Uint8Array(data)); - expect(spy).to.have.been.calledOnce; - expect(spy.args[0][0].detail.text).to.equal(expectedData); - client.removeEventListener("clipboard", spy); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedData + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedData: expectedData}}) + )).to.be.true; }); - it('should be able to handle large Provide messages', function () { + it('should be able to handle large Provide messages', async function () { + client._viewOnly = false; + client._asyncClipboard = { + writeClipboard: sinon.stub().returns(false), + }; let expectedData = "hello".repeat(100000); let data = [3, 0, 0, 0]; const flags = [0x10, 0x00, 0x00, 0x01]; @@ -3593,13 +3701,16 @@ describe('Remote Frame Buffer protocol client', function () { data = data.concat(flags); data = data.concat(Array.from(deflatedText)); - const spy = sinon.spy(); - client.addEventListener("clipboard", spy); + const dispatchEventSpy = sinon.spy(client, 'dispatchEvent'); client._sock._websocket._receiveData(new Uint8Array(data)); - expect(spy).to.have.been.calledOnce; - expect(spy.args[0][0].detail.text).to.equal(expectedData); - client.removeEventListener("clipboard", spy); + + expect(client._asyncClipboard.writeClipboard.calledOnceWith( + expectedData + )).to.be.true; + expect(dispatchEventSpy.calledOnceWith( + new CustomEvent("clipboard", {detail: {expectedData: expectedData}}) + )).to.be.true; }); }); diff --git a/tests/test.websock.js b/tests/test.websock.js index 62bcbfa59..110e6ad07 100644 --- a/tests/test.websock.js +++ b/tests/test.websock.js @@ -47,6 +47,20 @@ describe('Websock', function () { }); }); + describe('rQlen())', function () { + it('should return the number of buffered bytes in the receive queue', function () { + websock._receiveData(new Uint8Array([0xab, 0xcd, 0x12, 0x34, + 0x88, 0xee, 0x11, 0x33])); + expect(sock.rQlen()).to.equal(8); + sock.rQshift8(); + expect(sock.rQlen()).to.equal(7); + sock.rQshift16(); + expect(sock.rQlen()).to.equal(5); + sock.rQshift32(); + expect(sock.rQlen()).to.equal(1); + }); + }); + describe('rQshiftStr', function () { it('should shift the given number of bytes off of the receive queue and return a string', function () { websock._receiveData(new Uint8Array([0xab, 0xcd, 0x12, 0x34, diff --git a/utils/convert.js b/utils/convert.js deleted file mode 100755 index 617f4ed65..000000000 --- a/utils/convert.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node - -const path = require('path'); -const { program } = require('commander'); -const fs = require('fs'); -const fse = require('fs-extra'); -const babel = require('@babel/core'); - -program - .option('-m, --with-source-maps [type]', 'output source maps when not generating a bundled app (type may be empty for external source maps, inline for inline source maps, or both) ') - .option('--clean', 'clear the lib folder before building') - .parse(process.argv); - -// the various important paths -const paths = { - main: path.resolve(__dirname, '..'), - core: path.resolve(__dirname, '..', 'core'), - vendor: path.resolve(__dirname, '..', 'vendor'), - libDirBase: path.resolve(__dirname, '..', 'lib'), -}; - -// util.promisify requires Node.js 8.x, so we have our own -function promisify(original) { - return function promiseWrap() { - const args = Array.prototype.slice.call(arguments); - return new Promise((resolve, reject) => { - original.apply(this, args.concat((err, value) => { - if (err) return reject(err); - resolve(value); - })); - }); - }; -} - -const writeFile = promisify(fs.writeFile); - -const readdir = promisify(fs.readdir); -const lstat = promisify(fs.lstat); - -const ensureDir = promisify(fse.ensureDir); - -const babelTransformFile = promisify(babel.transformFile); - -// walkDir *recursively* walks directories trees, -// calling the callback for all normal files found. -function walkDir(basePath, cb, filter) { - return readdir(basePath) - .then((files) => { - const paths = files.map(filename => path.join(basePath, filename)); - return Promise.all(paths.map(filepath => lstat(filepath) - .then((stats) => { - if (filter !== undefined && !filter(filepath, stats)) return; - - if (stats.isSymbolicLink()) return; - if (stats.isFile()) return cb(filepath); - if (stats.isDirectory()) return walkDir(filepath, cb, filter); - }))); - }); -} - -function makeLibFiles(sourceMaps) { - // NB: we need to make a copy of babelOpts, since babel sets some defaults on it - const babelOpts = () => ({ - plugins: [], - presets: [ - [ '@babel/preset-env', - { modules: 'commonjs' } ] - ], - ast: false, - sourceMaps: sourceMaps, - }); - - fse.ensureDirSync(paths.libDirBase); - - const outFiles = []; - - const handleDir = (vendorRewrite, inPathBase, filename) => Promise.resolve() - .then(() => { - const outPath = path.join(paths.libDirBase, path.relative(inPathBase, filename)); - - if (path.extname(filename) !== '.js') { - return; // skip non-javascript files - } - return Promise.resolve() - .then(() => ensureDir(path.dirname(outPath))) - .then(() => { - const opts = babelOpts(); - // Adjust for the fact that we move the core files relative - // to the vendor directory - if (vendorRewrite) { - opts.plugins.push(["import-redirect", - {"root": paths.libDirBase, - "redirect": { "vendor/(.+)": "./vendor/$1"}}]); - } - - return babelTransformFile(filename, opts) - .then((res) => { - console.log(`Writing ${outPath}`); - const {map} = res; - let {code} = res; - if (sourceMaps === true) { - // append URL for external source map - code += `\n//# sourceMappingURL=${path.basename(outPath)}.map\n`; - } - outFiles.push(`${outPath}`); - return writeFile(outPath, code) - .then(() => { - if (sourceMaps === true || sourceMaps === 'both') { - console.log(` and ${outPath}.map`); - outFiles.push(`${outPath}.map`); - return writeFile(`${outPath}.map`, JSON.stringify(map)); - } - }); - }); - }); - }); - - Promise.resolve() - .then(() => { - const handler = handleDir.bind(null, false, paths.main); - return walkDir(paths.vendor, handler); - }) - .then(() => { - const handler = handleDir.bind(null, true, paths.core); - return walkDir(paths.core, handler); - }) - .catch((err) => { - console.error(`Failure converting modules: ${err}`); - process.exit(1); - }); -} - -let options = program.opts(); - -if (options.clean) { - console.log(`Removing ${paths.libDirBase}`); - fse.removeSync(paths.libDirBase); -} - -makeLibFiles(options.withSourceMaps); diff --git a/utils/genkeysymdef.js b/utils/genkeysymdef.js index b10240ec4..b41c40ebd 100755 --- a/utils/genkeysymdef.js +++ b/utils/genkeysymdef.js @@ -7,7 +7,7 @@ "use strict"; -const fs = require('fs'); +import fs from 'fs'; let showHelp = process.argv.length === 2; let filename; diff --git a/utils/novnc_proxy b/utils/novnc_proxy index 6b55504a5..2f29a772b 100755 --- a/utils/novnc_proxy +++ b/utils/novnc_proxy @@ -181,10 +181,8 @@ if [[ -d ${HERE}/websockify ]]; then echo "Using local websockify at $WEBSOCKIFY" else - WEBSOCKIFY_FROMSYSTEM=$(which websockify 2>/dev/null) - WEBSOCKIFY_FROMSNAP=${HERE}/../usr/bin/python2-websockify + WEBSOCKIFY_FROMSYSTEM=$(type -P websockify 2>/dev/null) [ -f $WEBSOCKIFY_FROMSYSTEM ] && WEBSOCKIFY=$WEBSOCKIFY_FROMSYSTEM - [ -f $WEBSOCKIFY_FROMSNAP ] && WEBSOCKIFY=$WEBSOCKIFY_FROMSNAP if [ ! -f "$WEBSOCKIFY" ]; then echo "No installed websockify, attempting to clone websockify..." diff --git a/vnc.html b/vnc.html index bb5aecec1..b4acb06ee 100644 --- a/vnc.html +++ b/vnc.html @@ -67,6 +67,8 @@ + +