diff --git a/public/novnc/LICENSE.txt b/public/novnc/LICENSE.txt index ee81d202..37efdcdb 100644 --- a/public/novnc/LICENSE.txt +++ b/public/novnc/LICENSE.txt @@ -1,4 +1,4 @@ -noVNC is Copyright (C) 2019 The noVNC Authors +noVNC is Copyright (C) 2022 The noVNC Authors (./AUTHORS) The noVNC core library files are licensed under the MPL 2.0 (Mozilla diff --git a/public/novnc/app/error-handler.js b/public/novnc/app/error-handler.js index 81a6cba8..67b63720 100644 --- a/public/novnc/app/error-handler.js +++ b/public/novnc/app/error-handler.js @@ -6,61 +6,74 @@ * See README.md for usage and integration instructions. */ -// NB: this should *not* be included as a module until we have -// native support in the browsers, so that our error handler -// can catch script-loading errors. +// Fallback for all uncought errors +function handleError(event, err) { + try { + const msg = document.getElementById('noVNC_fallback_errormsg'); -// No ES6 can be used in this file since it's used for the translation -/* eslint-disable prefer-arrow-callback */ - -(function _scope() { - "use strict"; - - // Fallback for all uncought errors - function handleError(event, err) { - try { - const msg = document.getElementById('noVNC_fallback_errormsg'); - - // Only show the initial error - if (msg.hasChildNodes()) { - return false; - } - - let div = document.createElement("div"); - div.classList.add('noVNC_message'); - div.appendChild(document.createTextNode(event.message)); - msg.appendChild(div); - - if (event.filename) { - div = document.createElement("div"); - div.className = 'noVNC_location'; - let text = event.filename; - if (event.lineno !== undefined) { - text += ":" + event.lineno; - if (event.colno !== undefined) { - text += ":" + event.colno; - } - } - div.appendChild(document.createTextNode(text)); - msg.appendChild(div); - } - - if (err && err.stack) { - div = document.createElement("div"); - div.className = 'noVNC_stack'; - div.appendChild(document.createTextNode(err.stack)); - msg.appendChild(div); - } - - document.getElementById('noVNC_fallback_error') - .classList.add("noVNC_open"); - } catch (exc) { - document.write("noVNC encountered an error."); + // Work around Firefox bug: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1685038 + if (event.message === "ResizeObserver loop completed with undelivered notifications.") { + return false; } - // Don't return true since this would prevent the error - // from being printed to the browser console. - return false; + + // Only show the initial error + if (msg.hasChildNodes()) { + return false; + } + + let div = document.createElement("div"); + div.classList.add('noVNC_message'); + div.appendChild(document.createTextNode(event.message)); + msg.appendChild(div); + + if (event.filename) { + div = document.createElement("div"); + div.className = 'noVNC_location'; + let text = event.filename; + if (event.lineno !== undefined) { + text += ":" + event.lineno; + if (event.colno !== undefined) { + text += ":" + event.colno; + } + } + div.appendChild(document.createTextNode(text)); + msg.appendChild(div); + } + + if (err && err.stack) { + div = document.createElement("div"); + div.className = 'noVNC_stack'; + div.appendChild(document.createTextNode(err.stack)); + msg.appendChild(div); + } + + document.getElementById('noVNC_fallback_error') + .classList.add("noVNC_open"); + + } catch (exc) { + document.write("noVNC encountered an error."); } - window.addEventListener('error', function onerror(evt) { handleError(evt, evt.error); }); - window.addEventListener('unhandledrejection', function onreject(evt) { handleError(evt.reason, evt.reason); }); -})(); + + // Try to disable keyboard interaction, best effort + try { + // Remove focus from the currently focused element in order to + // prevent keyboard interaction from continuing + if (document.activeElement) { document.activeElement.blur(); } + + // Don't let any element be focusable when showing the error + let keyboardFocusable = 'a[href], button, input, textarea, select, details, [tabindex]'; + document.querySelectorAll(keyboardFocusable).forEach((elem) => { + elem.setAttribute("tabindex", "-1"); + }); + } catch (exc) { + // Do nothing + } + + // Don't return true since this would prevent the error + // from being printed to the browser console. + return false; +} + +window.addEventListener('error', evt => handleError(evt, evt.error)); +window.addEventListener('unhandledrejection', evt => handleError(evt.reason, evt.reason)); diff --git a/public/novnc/app/images/icons/Makefile b/public/novnc/app/images/icons/Makefile index be564b43..03eaed07 100644 --- a/public/novnc/app/images/icons/Makefile +++ b/public/novnc/app/images/icons/Makefile @@ -1,42 +1,42 @@ -ICONS := \ - novnc-16x16.png \ - novnc-24x24.png \ - novnc-32x32.png \ - novnc-48x48.png \ - novnc-64x64.png +BROWSER_SIZES := 16 24 32 48 64 +#ANDROID_SIZES := 72 96 144 192 +# FIXME: The ICO is limited to 8 icons due to a Chrome bug: +# https://bugs.chromium.org/p/chromium/issues/detail?id=1381393 +ANDROID_SIZES := 96 144 192 +WEB_ICON_SIZES := $(BROWSER_SIZES) $(ANDROID_SIZES) -ANDROID_LAUNCHER := \ - novnc-48x48.png \ - novnc-72x72.png \ - novnc-96x96.png \ - novnc-144x144.png \ - novnc-192x192.png +#IOS_1X_SIZES := 20 29 40 76 # No such devices exist anymore +IOS_2X_SIZES := 40 58 80 120 152 167 +IOS_3X_SIZES := 60 87 120 180 +ALL_IOS_SIZES := $(IOS_1X_SIZES) $(IOS_2X_SIZES) $(IOS_3X_SIZES) -IPHONE_LAUNCHER := \ - novnc-60x60.png \ - novnc-120x120.png - -IPAD_LAUNCHER := \ - novnc-76x76.png \ - novnc-152x152.png - -ALL_ICONS := $(ICONS) $(ANDROID_LAUNCHER) $(IPHONE_LAUNCHER) $(IPAD_LAUNCHER) +ALL_ICONS := \ + $(ALL_IOS_SIZES:%=novnc-ios-%.png) \ + novnc.ico all: $(ALL_ICONS) -novnc-16x16.png: novnc-icon-sm.svg - convert -density 90 \ - -background transparent "$<" "$@" -novnc-24x24.png: novnc-icon-sm.svg - convert -density 135 \ - -background transparent "$<" "$@" -novnc-32x32.png: novnc-icon-sm.svg - convert -density 180 \ - -background transparent "$<" "$@" +# Our testing shows that the ICO file need to be sorted in largest to +# smallest to get the apporpriate behviour +WEB_ICON_SIZES_REVERSE := $(shell echo $(WEB_ICON_SIZES) | tr ' ' '\n' | sort -nr | tr '\n' ' ') +WEB_BASE_ICONS := $(WEB_ICON_SIZES_REVERSE:%=novnc-%.png) +.INTERMEDIATE: $(WEB_BASE_ICONS) +novnc.ico: $(WEB_BASE_ICONS) + convert $(WEB_BASE_ICONS) "$@" + +# General conversion novnc-%.png: novnc-icon.svg - convert -density $$[`echo $* | cut -d x -f 1` * 90 / 48] \ - -background transparent "$<" "$@" + convert -depth 8 -background transparent \ + -size $*x$* "$(lastword $^)" "$@" + +# iOS icons use their own SVG +novnc-ios-%.png: novnc-ios-icon.svg + convert -depth 8 -background transparent \ + -size $*x$* "$(lastword $^)" "$@" + +# The smallest sizes are generated using a different SVG +novnc-16.png novnc-24.png novnc-32.png: novnc-icon-sm.svg clean: rm -f *.png diff --git a/public/novnc/app/images/icons/novnc-120x120.png b/public/novnc/app/images/icons/novnc-120x120.png deleted file mode 100644 index 40823efb..00000000 Binary files a/public/novnc/app/images/icons/novnc-120x120.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-144x144.png b/public/novnc/app/images/icons/novnc-144x144.png deleted file mode 100644 index eee71f11..00000000 Binary files a/public/novnc/app/images/icons/novnc-144x144.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-152x152.png b/public/novnc/app/images/icons/novnc-152x152.png deleted file mode 100644 index 0694b2de..00000000 Binary files a/public/novnc/app/images/icons/novnc-152x152.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-16x16.png b/public/novnc/app/images/icons/novnc-16x16.png deleted file mode 100644 index 42108f40..00000000 Binary files a/public/novnc/app/images/icons/novnc-16x16.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-192x192.png b/public/novnc/app/images/icons/novnc-192x192.png deleted file mode 100644 index ef9201f4..00000000 Binary files a/public/novnc/app/images/icons/novnc-192x192.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-24x24.png b/public/novnc/app/images/icons/novnc-24x24.png deleted file mode 100644 index 11061359..00000000 Binary files a/public/novnc/app/images/icons/novnc-24x24.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-32x32.png b/public/novnc/app/images/icons/novnc-32x32.png deleted file mode 100644 index ff00dc30..00000000 Binary files a/public/novnc/app/images/icons/novnc-32x32.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-48x48.png b/public/novnc/app/images/icons/novnc-48x48.png deleted file mode 100644 index f24cd6cc..00000000 Binary files a/public/novnc/app/images/icons/novnc-48x48.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-60x60.png b/public/novnc/app/images/icons/novnc-60x60.png deleted file mode 100644 index 06b0d609..00000000 Binary files a/public/novnc/app/images/icons/novnc-60x60.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-64x64.png b/public/novnc/app/images/icons/novnc-64x64.png deleted file mode 100644 index 6d0fb341..00000000 Binary files a/public/novnc/app/images/icons/novnc-64x64.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-72x72.png b/public/novnc/app/images/icons/novnc-72x72.png deleted file mode 100644 index 23163a22..00000000 Binary files a/public/novnc/app/images/icons/novnc-72x72.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-76x76.png b/public/novnc/app/images/icons/novnc-76x76.png deleted file mode 100644 index aef61c48..00000000 Binary files a/public/novnc/app/images/icons/novnc-76x76.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-96x96.png b/public/novnc/app/images/icons/novnc-96x96.png deleted file mode 100644 index 1a77c53f..00000000 Binary files a/public/novnc/app/images/icons/novnc-96x96.png and /dev/null differ diff --git a/public/novnc/app/images/icons/novnc-ios-120.png b/public/novnc/app/images/icons/novnc-ios-120.png new file mode 100644 index 00000000..8da7bab3 Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-120.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-152.png b/public/novnc/app/images/icons/novnc-ios-152.png new file mode 100644 index 00000000..60b2bcef Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-152.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-167.png b/public/novnc/app/images/icons/novnc-ios-167.png new file mode 100644 index 00000000..98fade2e Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-167.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-180.png b/public/novnc/app/images/icons/novnc-ios-180.png new file mode 100644 index 00000000..5d24df70 Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-180.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-40.png b/public/novnc/app/images/icons/novnc-ios-40.png new file mode 100644 index 00000000..cf14894d Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-40.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-58.png b/public/novnc/app/images/icons/novnc-ios-58.png new file mode 100644 index 00000000..f6dfbebd Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-58.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-60.png b/public/novnc/app/images/icons/novnc-ios-60.png new file mode 100644 index 00000000..8cda2953 Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-60.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-80.png b/public/novnc/app/images/icons/novnc-ios-80.png new file mode 100644 index 00000000..6c417c47 Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-80.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-87.png b/public/novnc/app/images/icons/novnc-ios-87.png new file mode 100644 index 00000000..4377d874 Binary files /dev/null and b/public/novnc/app/images/icons/novnc-ios-87.png differ diff --git a/public/novnc/app/images/icons/novnc-ios-icon.svg b/public/novnc/app/images/icons/novnc-ios-icon.svg new file mode 100644 index 00000000..009452ac --- /dev/null +++ b/public/novnc/app/images/icons/novnc-ios-icon.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/novnc/app/images/icons/novnc.ico b/public/novnc/app/images/icons/novnc.ico new file mode 100644 index 00000000..c3bc58e3 Binary files /dev/null and b/public/novnc/app/images/icons/novnc.ico differ diff --git a/public/novnc/app/locale/fr.json b/public/novnc/app/locale/fr.json index 19e8255b..22531f73 100644 --- a/public/novnc/app/locale/fr.json +++ b/public/novnc/app/locale/fr.json @@ -1,21 +1,22 @@ { + "HTTPS is required for full functionality": "", "Connecting...": "En cours de connexion...", "Disconnecting...": "Déconnexion en cours...", "Reconnecting...": "Reconnexion en cours...", "Internal error": "Erreur interne", "Must set host": "Doit définir l'hôte", - "Connected (encrypted) to ": "Connecté (crypté) à ", - "Connected (unencrypted) to ": "Connecté (non crypté) à ", - "Something went wrong, connection is closed": "Quelque chose est arrivé, la connexion est fermée", + "Connected (encrypted) to ": "Connecté (chiffré) à ", + "Connected (unencrypted) to ": "Connecté (non chiffré) à ", + "Something went wrong, connection is closed": "Quelque chose s'est mal passé, la connexion a été fermée", "Failed to connect to server": "Échec de connexion au serveur", "Disconnected": "Déconnecté", - "New connection has been rejected with reason: ": "Une nouvelle connexion a été rejetée avec raison: ", + "New connection has been rejected with reason: ": "Une nouvelle connexion a été rejetée avec motif : ", "New connection has been rejected": "Une nouvelle connexion a été rejetée", "Credentials are required": "Les identifiants sont requis", - "noVNC encountered an error:": "noVNC a rencontré une erreur:", + "noVNC encountered an error:": "noVNC a rencontré une erreur :", "Hide/Show the control bar": "Masquer/Afficher la barre de contrôle", "Drag": "Faire glisser", - "Move/Drag Viewport": "Déplacer/faire glisser Viewport", + "Move/Drag Viewport": "Déplacer/faire glisser le Viewport", "Keyboard": "Clavier", "Show Keyboard": "Afficher le clavier", "Extra keys": "Touches supplémentaires", @@ -39,34 +40,39 @@ "Reboot": "Redémarrer", "Reset": "Réinitialiser", "Clipboard": "Presse-papiers", - "Clear": "Effacer", - "Fullscreen": "Plein écran", + "Edit clipboard content in the textarea below.": "", "Settings": "Paramètres", "Shared Mode": "Mode partagé", "View Only": "Afficher uniquement", "Clip to Window": "Clip à fenêtre", - "Scaling Mode:": "Mode mise à l'échelle:", + "Scaling Mode:": "Mode mise à l'échelle :", "None": "Aucun", "Local Scaling": "Mise à l'échelle locale", "Remote Resizing": "Redimensionnement à distance", "Advanced": "Avancé", - "Quality:": "Qualité:", - "Compression level:": "Niveau de compression:", - "Repeater ID:": "ID Répéteur:", + "Quality:": "Qualité :", + "Compression level:": "Niveau de compression :", + "Repeater ID:": "ID Répéteur :", "WebSocket": "WebSocket", - "Encrypt": "Crypter", - "Host:": "Hôte:", - "Port:": "Port:", - "Path:": "Chemin:", + "Encrypt": "Chiffrer", + "Host:": "Hôte :", + "Port:": "Port :", + "Path:": "Chemin :", "Automatic Reconnect": "Reconnecter automatiquemen", - "Reconnect Delay (ms):": "Délai de reconnexion (ms):", + "Reconnect Delay (ms):": "Délai de reconnexion (ms) :", "Show Dot when No Cursor": "Afficher le point lorsqu'il n'y a pas de curseur", - "Logging:": "Se connecter:", - "Version:": "Version:", + "Logging:": "Se connecter :", + "Version:": "Version :", "Disconnect": "Déconnecter", "Connect": "Connecter", - "Username:": "Nom d'utilisateur:", - "Password:": "Mot de passe:", + "Server identity": "", + "The server has provided the following identifying information:": "", + "Fingerprint:": "", + "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "", + "Approve": "", + "Reject": "", + "Username:": "Nom d'utilisateur :", + "Password:": "Mot de passe :", "Send Credentials": "Envoyer les identifiants", "Cancel": "Annuler" } \ No newline at end of file diff --git a/public/novnc/app/locale/it.json b/public/novnc/app/locale/it.json new file mode 100644 index 00000000..6fd25702 --- /dev/null +++ b/public/novnc/app/locale/it.json @@ -0,0 +1,72 @@ +{ + "Connecting...": "Connessione in corso...", + "Disconnecting...": "Disconnessione...", + "Reconnecting...": "Riconnessione...", + "Internal error": "Errore interno", + "Must set host": "Devi impostare l'host", + "Connected (encrypted) to ": "Connesso (crittografato) a ", + "Connected (unencrypted) to ": "Connesso (non crittografato) a", + "Something went wrong, connection is closed": "Qualcosa è andato storto, la connessione è stata chiusa", + "Failed to connect to server": "Impossibile connettersi al server", + "Disconnected": "Disconnesso", + "New connection has been rejected with reason: ": "La nuova connessione è stata rifiutata con motivo: ", + "New connection has been rejected": "La nuova connessione è stata rifiutata", + "Credentials are required": "Le credenziali sono obbligatorie", + "noVNC encountered an error:": "noVNC ha riscontrato un errore:", + "Hide/Show the control bar": "Nascondi/Mostra la barra di controllo", + "Drag": "", + "Move/Drag Viewport": "", + "Keyboard": "Tastiera", + "Show Keyboard": "Mostra tastiera", + "Extra keys": "Tasti Aggiuntivi", + "Show Extra Keys": "Mostra Tasti Aggiuntivi", + "Ctrl": "Ctrl", + "Toggle Ctrl": "Tieni premuto Ctrl", + "Alt": "Alt", + "Toggle Alt": "Tieni premuto Alt", + "Toggle Windows": "Tieni premuto Windows", + "Windows": "Windows", + "Send Tab": "Invia Tab", + "Tab": "Tab", + "Esc": "Esc", + "Send Escape": "Invia Esc", + "Ctrl+Alt+Del": "Ctrl+Alt+Canc", + "Send Ctrl-Alt-Del": "Invia Ctrl-Alt-Canc", + "Shutdown/Reboot": "Spegnimento/Riavvio", + "Shutdown/Reboot...": "Spegnimento/Riavvio...", + "Power": "Alimentazione", + "Shutdown": "Spegnimento", + "Reboot": "Riavvio", + "Reset": "Reset", + "Clipboard": "Clipboard", + "Clear": "Pulisci", + "Fullscreen": "Schermo intero", + "Settings": "Impostazioni", + "Shared Mode": "Modalità condivisa", + "View Only": "Sola Visualizzazione", + "Clip to Window": "", + "Scaling Mode:": "Modalità di ridimensionamento:", + "None": "Nessuna", + "Local Scaling": "Ridimensionamento Locale", + "Remote Resizing": "Ridimensionamento Remoto", + "Advanced": "Avanzate", + "Quality:": "Qualità:", + "Compression level:": "Livello Compressione:", + "Repeater ID:": "ID Ripetitore:", + "WebSocket": "WebSocket", + "Encrypt": "Crittografa", + "Host:": "Host:", + "Port:": "Porta:", + "Path:": "Percorso:", + "Automatic Reconnect": "Riconnessione Automatica", + "Reconnect Delay (ms):": "Ritardo Riconnessione (ms):", + "Show Dot when No Cursor": "Mostra Punto quando Nessun Cursore", + "Logging:": "", + "Version:": "Versione:", + "Disconnect": "Disconnetti", + "Connect": "Connetti", + "Username:": "Utente:", + "Password:": "Password:", + "Send Credentials": "Invia Credenziale", + "Cancel": "Annulla" +} \ No newline at end of file diff --git a/public/novnc/app/locale/sv.json b/public/novnc/app/locale/sv.json index e46df45b..077ef42c 100644 --- a/public/novnc/app/locale/sv.json +++ b/public/novnc/app/locale/sv.json @@ -1,4 +1,5 @@ { + "HTTPS is required for full functionality": "HTTPS krävs för full funktionalitet", "Connecting...": "Ansluter...", "Disconnecting...": "Kopplar ner...", "Reconnecting...": "Återansluter...", @@ -39,8 +40,8 @@ "Reboot": "Boota om", "Reset": "Återställ", "Clipboard": "Urklipp", - "Clear": "Rensa", - "Fullscreen": "Fullskärm", + "Edit clipboard content in the textarea below.": "Redigera urklippets innehåll i fältet nedan.", + "Full Screen": "Fullskärm", "Settings": "Inställningar", "Shared Mode": "Delat Läge", "View Only": "Endast Visning", @@ -65,6 +66,13 @@ "Version:": "Version:", "Disconnect": "Koppla från", "Connect": "Anslut", + "Server identity": "Server-identitet", + "The server has provided the following identifying information:": "Servern har gett följande identifierande information:", + "Fingerprint:": "Fingeravtryck:", + "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "Kontrollera att informationen är korrekt och tryck sedan \"Godkänn\". Tryck annars \"Neka\".", + "Approve": "Godkänn", + "Reject": "Neka", + "Credentials": "Användaruppgifter", "Username:": "Användarnamn:", "Password:": "Lösenord:", "Send Credentials": "Skicka Användaruppgifter", diff --git a/public/novnc/app/localization.js b/public/novnc/app/localization.js index 100901c9..84341da6 100644 --- a/public/novnc/app/localization.js +++ b/public/novnc/app/localization.js @@ -103,13 +103,20 @@ export class Localizer { return items.indexOf(searchElement) !== -1; } + function translateString(str) { + // We assume surrounding whitespace, and whitespace around line + // breaks is just for source formatting + str = str.split("\n").map(s => s.trim()).join(" ").trim(); + return self.get(str); + } + function translateAttribute(elem, attr) { - const str = self.get(elem.getAttribute(attr)); + const str = translateString(elem.getAttribute(attr)); elem.setAttribute(attr, str); } function translateTextNode(node) { - const str = self.get(node.data.trim()); + const str = translateString(node.data); node.data = str; } diff --git a/public/novnc/app/styles/base.css b/public/novnc/app/styles/base.css index fd78b79c..06e736a9 100644 --- a/public/novnc/app/styles/base.css +++ b/public/novnc/app/styles/base.css @@ -19,10 +19,23 @@ * 10000: Max (used for polyfills) */ +/* + * State variables (set on :root): + * + * noVNC_loading: Page is still loading + * noVNC_connecting: Connecting to server + * noVNC_reconnecting: Re-establishing a connection + * noVNC_connected: Connected to server (most common state) + * noVNC_disconnecting: Disconnecting from server + */ + +:root { + font-family: sans-serif; +} + body { margin:0; padding:0; - font-family: Helvetica; /*Background image with light grey curve.*/ background-color:#494949; background-repeat:no-repeat; @@ -78,144 +91,6 @@ html { 50% { box-shadow: 60px 10px 0 rgba(255, 255, 255, 0); width: 10px; } } -/* ---------------------------------------- - * Input Elements - * ---------------------------------------- - */ - -input:not([type]), -input[type=date], -input[type=datetime-local], -input[type=email], -input[type=month], -input[type=number], -input[type=password], -input[type=search], -input[type=tel], -input[type=text], -input[type=time], -input[type=url], -input[type=week], -textarea { - /* Disable default rendering */ - -webkit-appearance: none; - -moz-appearance: none; - background: none; - - margin: 2px; - padding: 2px; - border: 1px solid rgb(192, 192, 192); - border-radius: 5px; - color: black; - background: linear-gradient(to top, rgb(255, 255, 255) 80%, rgb(240, 240, 240)); -} - -input[type=button], -input[type=color], -input[type=reset], -input[type=submit], -select { - /* Disable default rendering */ - -webkit-appearance: none; - -moz-appearance: none; - background: none; - - margin: 2px; - padding: 2px; - border: 1px solid rgb(192, 192, 192); - border-bottom-width: 2px; - border-radius: 5px; - color: black; - background: linear-gradient(to top, rgb(255, 255, 255), rgb(240, 240, 240)); - - /* This avoids it jumping around when :active */ - vertical-align: middle; -} - -input[type=button], -input[type=color], -input[type=reset], -input[type=submit] { - padding-left: 20px; - padding-right: 20px; -} - -option { - color: black; - background: white; -} - -input:not([type]):focus, -input[type=button]:focus, -input[type=color]:focus, -input[type=date]:focus, -input[type=datetime-local]:focus, -input[type=email]:focus, -input[type=month]:focus, -input[type=number]:focus, -input[type=password]:focus, -input[type=reset]:focus, -input[type=search]:focus, -input[type=submit]:focus, -input[type=tel]:focus, -input[type=text]:focus, -input[type=time]:focus, -input[type=url]:focus, -input[type=week]:focus, -select:focus, -textarea:focus { - box-shadow: 0px 0px 3px rgba(74, 144, 217, 0.5); - border-color: rgb(74, 144, 217); - outline: none; -} - -input[type=button]::-moz-focus-inner, -input[type=color]::-moz-focus-inner, -input[type=reset]::-moz-focus-inner, -input[type=submit]::-moz-focus-inner { - border: none; -} - -input:not([type]):disabled, -input[type=button]:disabled, -input[type=color]:disabled, -input[type=date]:disabled, -input[type=datetime-local]:disabled, -input[type=email]:disabled, -input[type=month]:disabled, -input[type=number]:disabled, -input[type=password]:disabled, -input[type=reset]:disabled, -input[type=search]:disabled, -input[type=submit]:disabled, -input[type=tel]:disabled, -input[type=text]:disabled, -input[type=time]:disabled, -input[type=url]:disabled, -input[type=week]:disabled, -select:disabled, -textarea:disabled { - color: rgb(128, 128, 128); - background: rgb(240, 240, 240); -} - -input[type=button]:active, -input[type=color]:active, -input[type=reset]:active, -input[type=submit]:active, -select:active { - border-bottom-width: 1px; - margin-top: 3px; -} - -:root:not(.noVNC_touch) input[type=button]:hover:not(:disabled), -:root:not(.noVNC_touch) input[type=color]:hover:not(:disabled), -:root:not(.noVNC_touch) input[type=reset]:hover:not(:disabled), -:root:not(.noVNC_touch) input[type=submit]:hover:not(:disabled), -:root:not(.noVNC_touch) select:hover:not(:disabled) { - background: linear-gradient(to top, rgb(255, 255, 255), rgb(250, 250, 250)); -} - /* ---------------------------------------- * WebKit centering hacks * ---------------------------------------- @@ -242,13 +117,15 @@ select:active { pointer-events: auto; } .noVNC_vcenter { - display: flex; + display: flex !important; flex-direction: column; justify-content: center; position: fixed; top: 0; left: 0; height: 100%; + margin: 0 !important; + padding: 0 !important; pointer-events: none; } .noVNC_vcenter > * { @@ -272,13 +149,20 @@ select:active { #noVNC_fallback_error { z-index: 1000; visibility: hidden; + /* Put a dark background in front of everything but the error, + and don't let mouse events pass through */ + background: rgba(0, 0, 0, 0.8); + pointer-events: all; } #noVNC_fallback_error.noVNC_open { visibility: visible; } #noVNC_fallback_error > div { - max-width: 90%; + max-width: calc(100vw - 30px - 30px); + max-height: calc(100vh - 30px - 30px); + overflow: auto; + padding: 15px; transition: 0.5s ease-in-out; @@ -317,7 +201,6 @@ select:active { } #noVNC_fallback_error .noVNC_stack { - max-height: 50vh; padding: 10px; margin: 10px; font-size: 0.8em; @@ -361,6 +244,9 @@ select:active { background-color: rgb(110, 132, 163); border-radius: 0 10px 10px 0; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; /* Disable iOS image long-press popup */ } #noVNC_control_bar.noVNC_open { box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); @@ -433,38 +319,50 @@ select:active { .noVNC_right #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { transform: none; } +/* Larger touch area for the handle, used when a touch screen is available */ #noVNC_control_bar_handle div { position: absolute; right: -35px; top: 0; width: 50px; - height: 50px; -} -:root:not(.noVNC_touch) #noVNC_control_bar_handle div { + height: 100%; display: none; } +@media (any-pointer: coarse) { + #noVNC_control_bar_handle div { + display: initial; + } +} .noVNC_right #noVNC_control_bar_handle div { left: -35px; right: auto; } -#noVNC_control_bar .noVNC_scroll { +#noVNC_control_bar > .noVNC_scroll { max-height: 100vh; /* Chrome is buggy with 100% */ overflow-x: hidden; overflow-y: auto; - padding: 0 10px 0 5px; + padding: 0 10px; } -.noVNC_right #noVNC_control_bar .noVNC_scroll { - padding: 0 5px 0 10px; + +#noVNC_control_bar > .noVNC_scroll > * { + display: block; + margin: 10px auto; } /* Control bar hint */ -#noVNC_control_bar_hint { +#noVNC_hint_anchor { position: fixed; - left: calc(100vw - 50px); + right: -50px; + left: auto; +} +#noVNC_control_bar_anchor.noVNC_right + #noVNC_hint_anchor { + left: -50px; right: auto; - top: 50%; - transform: translateY(-50%) scale(0); +} +#noVNC_control_bar_hint { + position: relative; + transform: scale(0); width: 100px; height: 50%; max-height: 600px; @@ -477,61 +375,65 @@ select:active { border-radius: 10px; transition-delay: 0s; } -#noVNC_control_bar_anchor.noVNC_right #noVNC_control_bar_hint{ - left: auto; - right: calc(100vw - 50px); -} #noVNC_control_bar_hint.noVNC_active { visibility: visible; opacity: 1; transition-delay: 0.2s; - transform: translateY(-50%) scale(1); + transform: scale(1); +} +#noVNC_control_bar_hint.noVNC_notransition { + transition: none !important; } -/* General button style */ -.noVNC_button { - display: block; +/* Control bar buttons */ +#noVNC_control_bar .noVNC_button { padding: 4px 4px; - margin: 10px 0; vertical-align: middle; border:1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; + background-color: transparent; + background-image: unset; /* we don't want the gradiant from input.css */ } -.noVNC_button.noVNC_selected { +#noVNC_control_bar .noVNC_button.noVNC_selected { border-color: rgba(0, 0, 0, 0.8); - background: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.5); } -.noVNC_button:disabled { - opacity: 0.4; +#noVNC_control_bar .noVNC_button.noVNC_selected:not(:disabled):hover { + border-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.2); } -.noVNC_button:focus { - outline: none; +#noVNC_control_bar .noVNC_button:not(:disabled):hover { + background-color: rgba(255, 255, 255, 0.2); } -.noVNC_button:active { +#noVNC_control_bar .noVNC_button:not(:disabled):active { padding-top: 5px; padding-bottom: 3px; } -/* Android browsers don't properly update hover state if touch events - * are intercepted, but focus should be safe to display */ -:root:not(.noVNC_touch) .noVNC_button.noVNC_selected:hover, -.noVNC_button.noVNC_selected:focus { - border-color: rgba(0, 0, 0, 0.4); - background: rgba(0, 0, 0, 0.2); +#noVNC_control_bar .noVNC_button.noVNC_hidden { + display: none !important; } -:root:not(.noVNC_touch) .noVNC_button:hover, -.noVNC_button:focus { - background: rgba(255, 255, 255, 0.2); -} -.noVNC_button.noVNC_hidden { - display: none; + +/* Android browsers don't properly update hover state if touch events are + * intercepted, like they are when clicking on the remote screen. */ +@media (any-pointer: coarse) { + #noVNC_control_bar .noVNC_button:not(:disabled):hover { + background-color: transparent; + } + #noVNC_control_bar .noVNC_button.noVNC_selected:not(:disabled):hover { + border-color: rgba(0, 0, 0, 0.8); + background-color: rgba(0, 0, 0, 0.5); + } } + /* Panels */ .noVNC_panel { transform: translateX(25px); transition: 0.5s ease-in-out; + box-sizing: border-box; /* so max-width don't have to care about padding */ + max-width: calc(100vw - 75px - 25px); /* minus left and right margins */ max-height: 100vh; /* Chrome is buggy with 100% */ overflow-x: hidden; overflow-y: auto; @@ -563,6 +465,17 @@ select:active { transform: translateX(-75px); } +.noVNC_panel > * { + display: block; + margin: 10px auto; +} +.noVNC_panel > *:first-child { + margin-top: 0 !important; +} +.noVNC_panel > *:last-child { + margin-bottom: 0 !important; +} + .noVNC_panel hr { border: none; border-top: 1px solid rgb(192, 192, 192); @@ -571,6 +484,11 @@ select:active { .noVNC_panel label { display: block; white-space: nowrap; + margin: 5px; +} + +.noVNC_panel li { + margin: 5px; } .noVNC_panel .noVNC_heading { @@ -581,7 +499,6 @@ select:active { padding-right: 8px; color: white; font-size: 20px; - margin-bottom: 10px; white-space: nowrap; } .noVNC_panel .noVNC_heading img { @@ -622,6 +539,12 @@ select:active { font-size: 13px; } +.noVNC_logo + hr { + /* Remove all but top border */ + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.2); +} + :root:not(.noVNC_connected) #noVNC_view_drag_button { display: none; } @@ -630,8 +553,15 @@ select:active { :root:not(.noVNC_connected) #noVNC_mobile_buttons { display: none; } -:root:not(.noVNC_touch) #noVNC_mobile_buttons { - display: none; +@media not all and (any-pointer: coarse) { + /* FIXME: The button for the virtual keyboard is the only button in this + group of "mobile buttons". It is bad to assume that no touch + devices have physical keyboards available. Hopefully we can get + a media query for this: + https://github.com/w3c/csswg-drafts/issues/3871 */ + :root.noVNC_connected #noVNC_mobile_buttons { + display: none; + } } /* Extra manual keys */ @@ -642,7 +572,7 @@ select:active { #noVNC_modifiers { background-color: rgb(92, 92, 92); border: none; - padding: 0 10px; + padding: 10px; } /* Shutdown/Reboot */ @@ -663,13 +593,16 @@ select:active { :root:not(.noVNC_connected) #noVNC_clipboard_button { display: none; } -#noVNC_clipboard { - /* Full screen, minus padding and left and right margins */ - max-width: calc(100vw - 2*15px - 75px - 25px); -} #noVNC_clipboard_text { - width: 500px; + width: 360px; + min-width: 150px; + height: 160px; + min-height: 70px; + + box-sizing: border-box; max-width: 100%; + /* minus approximate height of title, height of subtitle, and margin */ + max-height: calc(100vh - 10em - 25px); } /* Settings */ @@ -677,7 +610,6 @@ select:active { } #noVNC_settings ul { list-style: none; - margin: 0px; padding: 0px; } #noVNC_setting_port { @@ -803,36 +735,32 @@ select:active { font-size: calc(25vw - 30px); } } -#noVNC_connect_button { - cursor: pointer; +#noVNC_connect_dlg div { + padding: 12px; - padding: 10px; - - color: white; background-color: rgb(110, 132, 163); border-radius: 12px; - text-align: center; font-size: 20px; box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); } -#noVNC_connect_button div { - margin: 2px; +#noVNC_connect_button { + width: 100%; padding: 5px 30px; - border: 1px solid rgb(83, 99, 122); - border-bottom-width: 2px; + + cursor: pointer; + + border-color: rgb(83, 99, 122); border-radius: 5px; + background: linear-gradient(to top, rgb(110, 132, 163), rgb(99, 119, 147)); + color: white; /* This avoids it jumping around when :active */ vertical-align: middle; } -#noVNC_connect_button div:active { - border-bottom-width: 1px; - margin-top: 3px; -} -:root:not(.noVNC_touch) #noVNC_connect_button div:hover { +#noVNC_connect_button:hover { background: linear-gradient(to top, rgb(110, 132, 163), rgb(105, 125, 155)); } @@ -841,6 +769,23 @@ select:active { height: 1.3em; } +/* ---------------------------------------- + * Server verification Dialog + * ---------------------------------------- + */ + +#noVNC_verify_server_dlg { + position: relative; + + transform: translateY(-50px); +} +#noVNC_verify_server_dlg.noVNC_open { + transform: translateY(0); +} +#noVNC_fingerprint_block { + margin: 10px; +} + /* ---------------------------------------- * Password Dialog * ---------------------------------------- @@ -854,12 +799,8 @@ select:active { #noVNC_credentials_dlg.noVNC_open { transform: translateY(0); } -#noVNC_credentials_dlg ul { - list-style: none; - margin: 0px; - padding: 0px; -} -.noVNC_hidden { +#noVNC_username_block.noVNC_hidden, +#noVNC_password_block.noVNC_hidden { display: none; } @@ -871,7 +812,11 @@ select:active { /* Transition screen */ #noVNC_transition { - display: none; + transition: 0.5s ease-in-out; + + display: flex; + opacity: 0; + visibility: hidden; position: fixed; top: 0; @@ -892,7 +837,8 @@ select:active { :root.noVNC_connecting #noVNC_transition, :root.noVNC_disconnecting #noVNC_transition, :root.noVNC_reconnecting #noVNC_transition { - display: flex; + opacity: 1; + visibility: visible; } :root:not(.noVNC_reconnecting) #noVNC_cancel_reconnect_button { display: none; @@ -908,6 +854,12 @@ select:active { background-color: #313131; border-bottom-right-radius: 800px 600px; /*border-top-left-radius: 800px 600px;*/ + + /* If selection isn't disabled, long-pressing stuff in the sidebar + can accidentally select the container or the canvas. This can + happen when attempting to move the handle. */ + user-select: none; + -webkit-user-select: none; } #noVNC_keyboardinput { diff --git a/public/novnc/app/styles/input.css b/public/novnc/app/styles/input.css new file mode 100644 index 00000000..eaf083c7 --- /dev/null +++ b/public/novnc/app/styles/input.css @@ -0,0 +1,281 @@ +/* + * noVNC general input element CSS + * Copyright (C) 2022 The noVNC Authors + * noVNC is licensed under the MPL 2.0 (see LICENSE.txt) + * This file is licensed under the 2-Clause BSD license (see LICENSE.txt). + */ + +/* + * Common for all inputs + */ +input, input::file-selector-button, button, select, textarea { + /* Respect standard font settings */ + font: inherit; + + /* Disable default rendering */ + appearance: none; + background: none; + + padding: 5px; + border: 1px solid rgb(192, 192, 192); + border-radius: 5px; + color: black; + --bg-gradient: linear-gradient(to top, rgb(255, 255, 255) 80%, rgb(240, 240, 240)); + background-image: var(--bg-gradient); +} + +/* + * Buttons + */ +input[type=button], +input[type=color], +input[type=image], +input[type=reset], +input[type=submit], +input::file-selector-button, +button, +select { + border-bottom-width: 2px; + + /* This avoids it jumping around when :active */ + vertical-align: middle; + margin-top: 0; + + padding-left: 20px; + padding-right: 20px; + + /* Disable Chrome's touch tap highlight */ + -webkit-tap-highlight-color: transparent; +} + +/* + * Select dropdowns + */ +select { + --select-arrow: url('data:image/svg+xml;utf8, \ + \ + \ + '); + background-image: var(--select-arrow), var(--bg-gradient); + background-position: calc(100% - 7px), left top; + background-repeat: no-repeat; + padding-right: calc(2*7px + 8px); + padding-left: 7px; +} +/* FIXME: :active isn't set when the is opened in Firefox: + https://bugzilla.mozilla.org/show_bug.cgi?id=1805406 */ +select:active { + /* Rotated arrow */ + background-image: url('data:image/svg+xml;utf8, \ + \ + \ + '), var(--bg-gradient); +} +option { + color: black; + background: white; +} + +/* + * Checkboxes + */ +input[type=checkbox] { + background-color: white; + background-image: unset; + border: 1px solid dimgrey; + border-radius: 3px; + width: 13px; + height: 13px; + padding: 0; + margin-right: 6px; + vertical-align: bottom; + transition: 0.2s background-color linear; +} +input[type=checkbox]:checked { + background-color: rgb(110, 132, 163); + border-color: rgb(110, 132, 163); +} +input[type=checkbox]:checked::after { + content: ""; + display: block; /* width & height doesn't work on inline elements */ + position: relative; + top: 0; + left: 3px; + width: 3px; + height: 7px; + border: 1px solid white; + border-width: 0 2px 2px 0; + transform: rotate(40deg); +} + +/* + * Radiobuttons + */ +input[type=radio] { + border-radius: 50%; + border: 1px solid dimgrey; + width: 12px; + height: 12px; + padding: 0; + margin-right: 6px; + transition: 0.2s border linear; +} +input[type=radio]:checked { + border: 6px solid rgb(110, 132, 163); +} + +/* + * Range sliders + */ +input[type=range] { + border: unset; + border-radius: 3px; + height: 20px; + padding: 0; + background: transparent; +} +/* -webkit-slider.. & -moz-range.. cant be in selector lists: + https://bugs.chromium.org/p/chromium/issues/detail?id=1154623 */ +input[type=range]::-webkit-slider-runnable-track { + background-color: rgb(110, 132, 163); + height: 6px; + border-radius: 3px; +} +input[type=range]::-moz-range-track { + background-color: rgb(110, 132, 163); + height: 6px; + border-radius: 3px; +} +input[type=range]::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 20px; + border-radius: 5px; + background-color: white; + border: 1px solid dimgray; + margin-top: -7px; +} +input[type=range]::-moz-range-thumb { + appearance: none; + width: 18px; + height: 20px; + border-radius: 5px; + background-color: white; + border: 1px solid dimgray; + margin-top: -7px; +} + +/* + * File choosers + */ +input[type=file] { + background-image: none; + border: none; +} +input::file-selector-button { + margin-right: 6px; +} + +/* + * Hover + */ +input[type=button]:hover, +input[type=color]:hover, +input[type=image]:hover, +input[type=reset]:hover, +input[type=submit]:hover, +input::file-selector-button:hover, +button:hover { + background-image: linear-gradient(to top, rgb(255, 255, 255), rgb(250, 250, 250)); +} +select:hover { + background-image: var(--select-arrow), + linear-gradient(to top, rgb(255, 255, 255), rgb(250, 250, 250)); + background-position: calc(100% - 7px), left top; + background-repeat: no-repeat; +} +@media (any-pointer: coarse) { + /* We don't want a hover style after touch input */ + input[type=button]:hover, + input[type=color]:hover, + input[type=image]:hover, + input[type=reset]:hover, + input[type=submit]:hover, + input::file-selector-button:hover, + button:hover { + background-image: var(--bg-gradient); + } + select:hover { + background-image: var(--select-arrow), var(--bg-gradient); + } +} + +/* + * Active (clicked) + */ +input[type=button]:active, +input[type=color]:active, +input[type=image]:active, +input[type=reset]:active, +input[type=submit]:active, +input::file-selector-button:active, +button:active, +select:active { + border-bottom-width: 1px; + margin-top: 1px; +} + +/* + * Focus (tab) + */ +input:focus-visible, +input:focus-visible::file-selector-button, +button:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid rgb(74, 144, 217); + outline-offset: 1px; +} +input[type=file]:focus-visible { + outline: none; /* We outline the button instead of the entire element */ +} + +/* + * Disabled + */ +input:disabled, +input:disabled::file-selector-button, +button:disabled, +select:disabled, +textarea:disabled { + opacity: 0.4; +} +input[type=button]:disabled, +input[type=color]:disabled, +input[type=image]:disabled, +input[type=reset]:disabled, +input[type=submit]:disabled, +input:disabled::file-selector-button, +button:disabled, +select:disabled { + background-image: var(--bg-gradient); + border-bottom-width: 2px; + margin-top: 0; +} +input[type=file]:disabled { + background-image: none; +} +select:disabled { + background-image: var(--select-arrow), var(--bg-gradient); +} +input[type=image]:disabled { + /* See Firefox bug: + https://bugzilla.mozilla.org/show_bug.cgi?id=1798304 */ + cursor: default; +} diff --git a/public/novnc/app/ui.js b/public/novnc/app/ui.js index 8e997f77..33a51a77 100644 --- a/public/novnc/app/ui.js +++ b/public/novnc/app/ui.js @@ -8,7 +8,8 @@ import * as Log from '../core/util/logging.js'; import _, { l10n } from './localization.js'; -import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold } +import { isTouchDevice, isMac, isIOS, isAndroid, isChromeOS, isSafari, + hasScrollbarGutter, dragThreshold } from '../core/util/browser.js'; import { setCapture, getPointerEvent } from '../core/util/events.js'; import KeyTable from "../core/input/keysym.js"; @@ -79,13 +80,19 @@ const UI = { // Render default UI and initialize settings menu start() { - if (urlargs.name) { document.title = urlargs.name + " - noVNC"; } - UI.initSettings(); // Translate the DOM l10n.translateDOM(); + // We rely on modern APIs which might not be available in an + // insecure context + if (!window.isSecureContext) { + // FIXME: This gets hidden when connecting + UI.showStatus(_("HTTPS is required for full functionality"), 'error'); + } + + // Try to fetch version number fetch('./package.json') .then((response) => { if (!response.ok) { @@ -105,7 +112,6 @@ const UI = { // Adapt the interface for touch screen devices if (isTouchDevice) { - document.documentElement.classList.add("noVNC_touch"); // Remove the address bar setTimeout(() => window.scrollTo(0, 1), 100); } @@ -341,6 +347,10 @@ const UI = { document.getElementById("noVNC_cancel_reconnect_button") .addEventListener('click', UI.cancelReconnect); + document.getElementById("noVNC_approve_server_button") + .addEventListener('click', UI.approveServer); + document.getElementById("noVNC_reject_server_button") + .addEventListener('click', UI.rejectServer); document.getElementById("noVNC_credentials_button") .addEventListener('click', UI.setCredentials); }, @@ -350,8 +360,6 @@ const UI = { .addEventListener('click', UI.toggleClipboardPanel); document.getElementById("noVNC_clipboard_text") .addEventListener('change', UI.clipboardSend); - document.getElementById("noVNC_clipboard_clear_button") - .addEventListener('click', UI.clipboardClear); }, // Add a call to save settings when the element changes, @@ -470,6 +478,8 @@ const UI = { // State change closes dialogs as they may not be relevant // anymore UI.closeAllPanels(); + document.getElementById('noVNC_verify_server_dlg') + .classList.remove('noVNC_open'); document.getElementById('noVNC_credentials_dlg') .classList.remove('noVNC_open'); }, @@ -602,10 +612,20 @@ const UI = { // Consider this a movement of the handle UI.controlbarDrag = true; + + // The user has "followed" hint, let's hide it until the next drag + UI.showControlbarHint(false, false); }, - showControlbarHint(show) { + showControlbarHint(show, animate=true) { const hint = document.getElementById('noVNC_control_bar_hint'); + + if (animate) { + hint.classList.remove("noVNC_notransition"); + } else { + hint.classList.add("noVNC_notransition"); + } + if (show) { hint.classList.add("noVNC_active"); } else { @@ -979,11 +999,6 @@ const UI = { Log.Debug("<< UI.clipboardReceive"); }, - clipboardClear() { - document.getElementById('noVNC_clipboard_text').value = ""; - UI.rfb.clipboardPasteFrom(""); - }, - clipboardSend() { const text = document.getElementById('noVNC_clipboard_text').value; Log.Debug(">> UI.clipboardSend: " + text.substr(0, 40) + "..."); @@ -1039,14 +1054,19 @@ const UI = { UI.updateVisualState('connecting'); + + + UI.rfb = new RFB(document.getElementById('noVNC_container'), urlargs.ws, { shared: UI.getSetting('shared'), repeaterID: UI.getSetting('repeaterID'), credentials: { password: password } }); UI.rfb.addEventListener("connect", UI.connectFinished); UI.rfb.addEventListener("disconnect", UI.disconnectFinished); + UI.rfb.addEventListener("serververification", UI.serverVerify); UI.rfb.addEventListener("credentialsrequired", UI.credentials); UI.rfb.addEventListener("securityfailure", UI.securityFailed); + UI.rfb.addEventListener("clippingviewport", UI.updateViewDrag); UI.rfb.addEventListener("capabilities", UI.updatePowerButton); UI.rfb.addEventListener("clipboard", UI.clipboardReceive); UI.rfb.addEventListener("bell", UI.bell); @@ -1133,7 +1153,9 @@ const UI = { } else { UI.showStatus(_("Failed to connect to server"), 'error'); } - } else if (UI.getSetting('reconnect', false) === true && !UI.inhibitReconnect) { + } + // If reconnecting is allowed process it now + if (UI.getSetting('reconnect', false) === true && !UI.inhibitReconnect) { UI.updateVisualState('reconnecting'); const delay = parseInt(UI.getSetting('reconnect_delay')); @@ -1144,6 +1166,7 @@ const UI = { UI.showStatus(_("Disconnected"), 'normal'); } + UI.openControlbar(); UI.openConnectPanel(); }, @@ -1165,6 +1188,37 @@ const UI = { /* ------^------- * /CONNECTION * ============== + * SERVER VERIFY + * ------v------*/ + + async serverVerify(e) { + const type = e.detail.type; + if (type === 'RSA') { + const publickey = e.detail.publickey; + let fingerprint = await window.crypto.subtle.digest("SHA-1", publickey); + // The same fingerprint format as RealVNC + fingerprint = Array.from(new Uint8Array(fingerprint).slice(0, 8)).map( + x => x.toString(16).padStart(2, '0')).join('-'); + document.getElementById('noVNC_verify_server_dlg').classList.add('noVNC_open'); + document.getElementById('noVNC_fingerprint').innerHTML = fingerprint; + } + }, + + approveServer(e) { + e.preventDefault(); + document.getElementById('noVNC_verify_server_dlg').classList.remove('noVNC_open'); + UI.rfb.approveServer(); + }, + + rejectServer(e) { + e.preventDefault(); + document.getElementById('noVNC_verify_server_dlg').classList.remove('noVNC_open'); + UI.disconnect(); + }, + +/* ------^------- + * /SERVER VERIFY + * ============== * PASSWORD * ------v------*/ @@ -1288,13 +1342,25 @@ const UI = { const scaling = UI.getSetting('resize') === 'scale'; + // Some platforms have overlay scrollbars that are difficult + // to use in our case, which means we have to force panning + // FIXME: Working scrollbars can still be annoying to use with + // touch, so we should ideally be able to have both + // panning and scrollbars at the same time + + let brokenScrollbars = false; + + if (!hasScrollbarGutter) { + if (isIOS() || isAndroid() || isMac() || isChromeOS()) { + brokenScrollbars = true; + } + } + if (scaling) { // Can't be clipping if viewport is scaled to fit UI.forceSetting('view_clip', false); UI.rfb.clipViewport = false; - } else if (!hasScrollbarGutter) { - // Some platforms have scrollbars that are difficult - // to use in our case, so we always use our own panning + } else if (brokenScrollbars) { UI.forceSetting('view_clip', true); UI.rfb.clipViewport = true; } else { @@ -1325,7 +1391,8 @@ const UI = { const viewDragButton = document.getElementById('noVNC_view_drag_button'); - if (!UI.rfb.clipViewport && UI.rfb.dragViewport) { + if ((!UI.rfb.clipViewport || !UI.rfb.clippingViewport) && + UI.rfb.dragViewport) { // We are no longer clipping the viewport. Make sure // viewport drag isn't active when it can't be used. UI.rfb.dragViewport = false; @@ -1342,6 +1409,8 @@ const UI = { } else { viewDragButton.classList.add("noVNC_hidden"); } + + viewDragButton.disabled = !UI.rfb.clippingViewport; }, /* ------^------- @@ -1670,9 +1739,9 @@ const UI = { }, updateDesktopName(e) { - //UI.desktopName = e.detail.name; + // UI.desktopName = e.detail.name; // Display the desktop name in the document title - //document.title = e.detail.name + " - " + PAGE_TITLE; + // document.title = e.detail.name + " - " + PAGE_TITLE; }, bell(e) { @@ -1708,7 +1777,7 @@ const UI = { }; // Set up translations -const LINGUAS = ["cs", "de", "el", "es", "fr", "ja", "ko", "nl", "pl", "pt_BR", "ru", "sv", "tr", "zh_CN", "zh_TW"]; +const LINGUAS = ["cs", "de", "el", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt_BR", "ru", "sv", "tr", "zh_CN", "zh_TW"]; l10n.setup(LINGUAS); if (l10n.language === "en" || l10n.dictionary !== undefined) { UI.prime(); diff --git a/public/novnc/app/webutil.js b/public/novnc/app/webutil.js index d42b7f25..084c69f6 100644 --- a/public/novnc/app/webutil.js +++ b/public/novnc/app/webutil.js @@ -32,7 +32,7 @@ export function initLogging(level) { export function getQueryVar(name, defVal) { "use strict"; const re = new RegExp('.*[?&]' + name + '=([^]*)'), - match = ''.concat(document.location.href, window.location.hash).match(re); + match = ''.concat(document.location.href, window.location.hash).match(re); if (typeof defVal === 'undefined') { defVal = null; } if (match) { @@ -46,7 +46,7 @@ export function getQueryVar(name, defVal) { export function getHashVar(name, defVal) { "use strict"; const re = new RegExp('.*[]' + name + '=([^&]*)'), - match = document.location.hash.match(re); + match = document.location.hash.match(re); if (typeof defVal === 'undefined') { defVal = null; } if (match) { diff --git a/public/novnc/core/decoders/jpeg.js b/public/novnc/core/decoders/jpeg.js new file mode 100644 index 00000000..e1f2bdf8 --- /dev/null +++ b/public/novnc/core/decoders/jpeg.js @@ -0,0 +1,141 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2019 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + * + */ + +export default class JPEGDecoder { + constructor() { + // RealVNC will reuse the quantization tables + // and Huffman tables, so we need to cache them. + this._quantTables = []; + this._huffmanTables = []; + this._cachedQuantTables = []; + this._cachedHuffmanTables = []; + + this._jpegLength = 0; + this._segments = []; + } + + decodeRect(x, y, width, height, sock, display, depth) { + // A rect of JPEG encodings is simply a JPEG file + if (!this._parseJPEG(sock.rQslice(0))) { + return false; + } + const data = sock.rQshiftBytes(this._jpegLength); + if (this._quantTables.length != 0 && this._huffmanTables.length != 0) { + // If there are quantization tables and Huffman tables in the JPEG + // image, we can directly render it. + display.imageRect(x, y, width, height, "image/jpeg", data); + return true; + } else { + // Otherwise we need to insert cached tables. + const sofIndex = this._segments.findIndex( + x => x[1] == 0xC0 || x[1] == 0xC2 + ); + if (sofIndex == -1) { + throw new Error("Illegal JPEG image without SOF"); + } + let segments = this._segments.slice(0, sofIndex); + segments = segments.concat(this._quantTables.length ? + this._quantTables : + this._cachedQuantTables); + segments.push(this._segments[sofIndex]); + segments = segments.concat(this._huffmanTables.length ? + this._huffmanTables : + this._cachedHuffmanTables, + this._segments.slice(sofIndex + 1)); + let length = 0; + for (let i = 0; i < segments.length; i++) { + length += segments[i].length; + } + const data = new Uint8Array(length); + length = 0; + for (let i = 0; i < segments.length; i++) { + data.set(segments[i], length); + length += segments[i].length; + } + display.imageRect(x, y, width, height, "image/jpeg", data); + return true; + } + } + + _parseJPEG(buffer) { + if (this._quantTables.length != 0) { + this._cachedQuantTables = this._quantTables; + } + if (this._huffmanTables.length != 0) { + this._cachedHuffmanTables = this._huffmanTables; + } + this._quantTables = []; + this._huffmanTables = []; + this._segments = []; + let i = 0; + let bufferLength = buffer.length; + while (true) { + let j = i; + if (j + 2 > bufferLength) { + return false; + } + if (buffer[j] != 0xFF) { + throw new Error("Illegal JPEG marker received (byte: " + + buffer[j] + ")"); + } + const type = buffer[j+1]; + j += 2; + if (type == 0xD9) { + this._jpegLength = j; + this._segments.push(buffer.slice(i, j)); + return true; + } else if (type == 0xDA) { + // start of scan + let hasFoundEndOfScan = false; + for (let k = j + 3; k + 1 < bufferLength; k++) { + if (buffer[k] == 0xFF && buffer[k+1] != 0x00 && + !(buffer[k+1] >= 0xD0 && buffer[k+1] <= 0xD7)) { + j = k; + hasFoundEndOfScan = true; + break; + } + } + if (!hasFoundEndOfScan) { + return false; + } + this._segments.push(buffer.slice(i, j)); + i = j; + continue; + } else if (type >= 0xD0 && type < 0xD9 || type == 0x01) { + // No length after marker + this._segments.push(buffer.slice(i, j)); + i = j; + continue; + } + if (j + 2 > bufferLength) { + return false; + } + const length = (buffer[j] << 8) + buffer[j+1] - 2; + if (length < 0) { + throw new Error("Illegal JPEG length received (length: " + + length + ")"); + } + j += 2; + if (j + length > bufferLength) { + return false; + } + j += length; + const segment = buffer.slice(i, j); + if (type == 0xC4) { + // Huffman tables + this._huffmanTables.push(segment); + } else if (type == 0xDB) { + // Quantization tables + this._quantTables.push(segment); + } + this._segments.push(segment); + i = j; + } + } +} diff --git a/public/novnc/core/decoders/raw.js b/public/novnc/core/decoders/raw.js index e8ea178e..d08f7ba9 100644 --- a/public/novnc/core/decoders/raw.js +++ b/public/novnc/core/decoders/raw.js @@ -51,7 +51,7 @@ export default class RawDecoder { // Max sure the image is fully opaque for (let i = 0; i < pixels; i++) { - data[i * 4 + 3] = 255; + data[index + i * 4 + 3] = 255; } display.blitImage(x, curY, width, currHeight, data, index); diff --git a/public/novnc/core/decoders/zrle.js b/public/novnc/core/decoders/zrle.js new file mode 100644 index 00000000..97fbd58e --- /dev/null +++ b/public/novnc/core/decoders/zrle.js @@ -0,0 +1,185 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2021 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + * + */ + +import Inflate from "../inflator.js"; + +const ZRLE_TILE_WIDTH = 64; +const ZRLE_TILE_HEIGHT = 64; + +export default class ZRLEDecoder { + constructor() { + this._length = 0; + this._inflator = new Inflate(); + + this._pixelBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4); + this._tileBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4); + } + + decodeRect(x, y, width, height, sock, display, depth) { + if (this._length === 0) { + if (sock.rQwait("ZLib data length", 4)) { + return false; + } + this._length = sock.rQshift32(); + } + if (sock.rQwait("Zlib data", this._length)) { + return false; + } + + const data = sock.rQshiftBytes(this._length); + + this._inflator.setInput(data); + + for (let ty = y; ty < y + height; ty += ZRLE_TILE_HEIGHT) { + let th = Math.min(ZRLE_TILE_HEIGHT, y + height - ty); + + for (let tx = x; tx < x + width; tx += ZRLE_TILE_WIDTH) { + let tw = Math.min(ZRLE_TILE_WIDTH, x + width - tx); + + const tileSize = tw * th; + const subencoding = this._inflator.inflate(1)[0]; + if (subencoding === 0) { + // raw data + const data = this._readPixels(tileSize); + display.blitImage(tx, ty, tw, th, data, 0, false); + } else if (subencoding === 1) { + // solid + const background = this._readPixels(1); + display.fillRect(tx, ty, tw, th, [background[0], background[1], background[2]]); + } else if (subencoding >= 2 && subencoding <= 16) { + const data = this._decodePaletteTile(subencoding, tileSize, tw, th); + display.blitImage(tx, ty, tw, th, data, 0, false); + } else if (subencoding === 128) { + const data = this._decodeRLETile(tileSize); + display.blitImage(tx, ty, tw, th, data, 0, false); + } else if (subencoding >= 130 && subencoding <= 255) { + const data = this._decodeRLEPaletteTile(subencoding - 128, tileSize); + display.blitImage(tx, ty, tw, th, data, 0, false); + } else { + throw new Error('Unknown subencoding: ' + subencoding); + } + } + } + this._length = 0; + return true; + } + + _getBitsPerPixelInPalette(paletteSize) { + if (paletteSize <= 2) { + return 1; + } else if (paletteSize <= 4) { + return 2; + } else if (paletteSize <= 16) { + return 4; + } + } + + _readPixels(pixels) { + let data = this._pixelBuffer; + const buffer = this._inflator.inflate(3*pixels); + for (let i = 0, j = 0; i < pixels*4; i += 4, j += 3) { + data[i] = buffer[j]; + data[i + 1] = buffer[j + 1]; + data[i + 2] = buffer[j + 2]; + data[i + 3] = 255; // Add the Alpha + } + return data; + } + + _decodePaletteTile(paletteSize, tileSize, tilew, tileh) { + const data = this._tileBuffer; + const palette = this._readPixels(paletteSize); + const bitsPerPixel = this._getBitsPerPixelInPalette(paletteSize); + const mask = (1 << bitsPerPixel) - 1; + + let offset = 0; + let encoded = this._inflator.inflate(1)[0]; + + for (let y=0; y>shift) & mask; + + data[offset] = palette[indexInPalette * 4]; + data[offset + 1] = palette[indexInPalette * 4 + 1]; + data[offset + 2] = palette[indexInPalette * 4 + 2]; + data[offset + 3] = palette[indexInPalette * 4 + 3]; + offset += 4; + shift-=bitsPerPixel; + } + if (shift<8-bitsPerPixel && y= 128) { + indexInPalette -= 128; + length = this._readRLELength(); + } + if (indexInPalette > paletteSize) { + throw new Error('Too big index in palette: ' + indexInPalette + ', palette size: ' + paletteSize); + } + if (offset + length > tileSize) { + throw new Error('Too big rle length in palette mode: ' + length + ', allowed length is: ' + (tileSize - offset)); + } + + for (let j = 0; j < length; j++) { + data[offset * 4] = palette[indexInPalette * 4]; + data[offset * 4 + 1] = palette[indexInPalette * 4 + 1]; + data[offset * 4 + 2] = palette[indexInPalette * 4 + 2]; + data[offset * 4 + 3] = palette[indexInPalette * 4 + 3]; + offset++; + } + } + return data; + } + + _readRLELength() { + let length = 0; + let current = 0; + do { + current = this._inflator.inflate(1)[0]; + length += current; + } while (current === 255); + return length + 1; + } +} diff --git a/public/novnc/core/des.js b/public/novnc/core/des.js index d2f807b8..ba1ebde0 100644 --- a/public/novnc/core/des.js +++ b/public/novnc/core/des.js @@ -81,7 +81,7 @@ const PC2 = [13,16,10,23, 0, 4, 2,27,14, 5,20, 9,22,18,11, 3, 25, 7,15, 6,26,19,12, 1,40,51,30,36,46,54,29,39, 50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31 ], - totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28]; + totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28]; const z = 0x0; let a,b,c,d,e,f; diff --git a/public/novnc/core/display.js b/public/novnc/core/display.js index 701eba4a..bf8d5fab 100644 --- a/public/novnc/core/display.js +++ b/public/novnc/core/display.js @@ -224,6 +224,18 @@ export default class Display { this.viewportChangePos(0, 0); } + getImageData() { + return this._drawCtx.getImageData(0, 0, this.width, this.height); + } + + toDataURL(type, encoderOptions) { + return this._backbuffer.toDataURL(type, encoderOptions); + } + + toBlob(callback, type, quality) { + return this._backbuffer.toBlob(callback, type, quality); + } + // Track what parts of the visible canvas that need updating _damage(x, y, w, h) { if (x < this._damageBounds.left) { diff --git a/public/novnc/core/encodings.js b/public/novnc/core/encodings.js index 51c09929..2041b6e0 100644 --- a/public/novnc/core/encodings.js +++ b/public/novnc/core/encodings.js @@ -12,7 +12,9 @@ export const encodings = { encodingRRE: 2, encodingHextile: 5, encodingTight: 7, + encodingZRLE: 16, encodingTightPNG: -260, + encodingJPEG: 21, pseudoEncodingQualityLevel9: -23, pseudoEncodingQualityLevel0: -32, @@ -38,7 +40,9 @@ export function encodingName(num) { case encodings.encodingRRE: return "RRE"; case encodings.encodingHextile: return "Hextile"; case encodings.encodingTight: return "Tight"; + case encodings.encodingZRLE: return "ZRLE"; case encodings.encodingTightPNG: return "TightPNG"; + case encodings.encodingJPEG: return "JPEG"; default: return "[unknown encoding " + num + "]"; } } diff --git a/public/novnc/core/input/keyboard.js b/public/novnc/core/input/keyboard.js index 48f65cf6..ddb5ce09 100644 --- a/public/novnc/core/input/keyboard.js +++ b/public/novnc/core/input/keyboard.js @@ -153,6 +153,16 @@ export default class Keyboard { keysym = this._keyDownList[code]; } + // macOS doesn't send proper key releases if a key is pressed + // while meta is held down + if ((browser.isMac() || browser.isIOS()) && + (e.metaKey && code !== 'MetaLeft' && code !== 'MetaRight')) { + this._sendKeyEvent(keysym, code, true); + this._sendKeyEvent(keysym, code, false); + stopEvent(e); + return; + } + // macOS doesn't send proper key events for modifiers, only // state change events. That gets extra confusing for CapsLock // which toggles on each press, but not on release. So pretend diff --git a/public/novnc/core/ra2.js b/public/novnc/core/ra2.js new file mode 100644 index 00000000..81a8a895 --- /dev/null +++ b/public/novnc/core/ra2.js @@ -0,0 +1,567 @@ +import Base64 from './base64.js'; +import { encodeUTF8 } from './util/strings.js'; +import EventTargetMixin from './util/eventtarget.js'; + +export class AESEAXCipher { + constructor() { + this._rawKey = null; + this._ctrKey = null; + this._cbcKey = null; + this._zeroBlock = new Uint8Array(16); + this._prefixBlock0 = this._zeroBlock; + this._prefixBlock1 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + this._prefixBlock2 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]); + } + + async _encryptBlock(block) { + const encrypted = await window.crypto.subtle.encrypt({ + name: "AES-CBC", + iv: this._zeroBlock, + }, this._cbcKey, block); + return new Uint8Array(encrypted).slice(0, 16); + } + + async _initCMAC() { + const k1 = await this._encryptBlock(this._zeroBlock); + const k2 = new Uint8Array(16); + const v = k1[0] >>> 6; + for (let i = 0; i < 15; i++) { + k2[i] = (k1[i + 1] >> 6) | (k1[i] << 2); + k1[i] = (k1[i + 1] >> 7) | (k1[i] << 1); + } + const lut = [0x0, 0x87, 0x0e, 0x89]; + k2[14] ^= v >>> 1; + k2[15] = (k1[15] << 2) ^ lut[v]; + k1[15] = (k1[15] << 1) ^ lut[v >> 1]; + this._k1 = k1; + this._k2 = k2; + } + + async _encryptCTR(data, counter) { + const encrypted = await window.crypto.subtle.encrypt({ + "name": "AES-CTR", + counter: counter, + length: 128 + }, this._ctrKey, data); + return new Uint8Array(encrypted); + } + + async _decryptCTR(data, counter) { + const decrypted = await window.crypto.subtle.decrypt({ + "name": "AES-CTR", + counter: counter, + length: 128 + }, this._ctrKey, data); + return new Uint8Array(decrypted); + } + + async _computeCMAC(data, prefixBlock) { + if (prefixBlock.length !== 16) { + return null; + } + const n = Math.floor(data.length / 16); + const m = Math.ceil(data.length / 16); + const r = data.length - n * 16; + const cbcData = new Uint8Array((m + 1) * 16); + cbcData.set(prefixBlock); + cbcData.set(data, 16); + if (r === 0) { + for (let i = 0; i < 16; i++) { + cbcData[n * 16 + i] ^= this._k1[i]; + } + } else { + cbcData[(n + 1) * 16 + r] = 0x80; + for (let i = 0; i < 16; i++) { + cbcData[(n + 1) * 16 + i] ^= this._k2[i]; + } + } + let cbcEncrypted = await window.crypto.subtle.encrypt({ + name: "AES-CBC", + iv: this._zeroBlock, + }, this._cbcKey, cbcData); + + cbcEncrypted = new Uint8Array(cbcEncrypted); + const mac = cbcEncrypted.slice(cbcEncrypted.length - 32, cbcEncrypted.length - 16); + return mac; + } + + async setKey(key) { + this._rawKey = key; + this._ctrKey = await window.crypto.subtle.importKey( + "raw", key, {"name": "AES-CTR"}, false, ["encrypt", "decrypt"]); + this._cbcKey = await window.crypto.subtle.importKey( + "raw", key, {"name": "AES-CBC"}, false, ["encrypt", "decrypt"]); + await this._initCMAC(); + } + + async encrypt(message, associatedData, nonce) { + const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0); + const encrypted = await this._encryptCTR(message, nCMAC); + const adCMAC = await this._computeCMAC(associatedData, this._prefixBlock1); + const mac = await this._computeCMAC(encrypted, this._prefixBlock2); + for (let i = 0; i < 16; i++) { + mac[i] ^= nCMAC[i] ^ adCMAC[i]; + } + const res = new Uint8Array(16 + encrypted.length); + res.set(encrypted); + res.set(mac, encrypted.length); + return res; + } + + async decrypt(encrypted, associatedData, nonce, mac) { + const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0); + const adCMAC = await this._computeCMAC(associatedData, this._prefixBlock1); + const computedMac = await this._computeCMAC(encrypted, this._prefixBlock2); + for (let i = 0; i < 16; i++) { + computedMac[i] ^= nCMAC[i] ^ adCMAC[i]; + } + if (computedMac.length !== mac.length) { + return null; + } + for (let i = 0; i < mac.length; i++) { + if (computedMac[i] !== mac[i]) { + return null; + } + } + const res = await this._decryptCTR(encrypted, nCMAC); + return res; + } +} + +export class RA2Cipher { + constructor() { + this._cipher = new AESEAXCipher(); + this._counter = new Uint8Array(16); + } + + async setKey(key) { + await this._cipher.setKey(key); + } + + async makeMessage(message) { + const ad = new Uint8Array([(message.length & 0xff00) >>> 8, message.length & 0xff]); + const encrypted = await this._cipher.encrypt(message, ad, this._counter); + for (let i = 0; i < 16 && this._counter[i]++ === 255; i++); + const res = new Uint8Array(message.length + 2 + 16); + res.set(ad); + res.set(encrypted, 2); + return res; + } + + async receiveMessage(length, encrypted, mac) { + const ad = new Uint8Array([(length & 0xff00) >>> 8, length & 0xff]); + const res = await this._cipher.decrypt(encrypted, ad, this._counter, mac); + for (let i = 0; i < 16 && this._counter[i]++ === 255; i++); + return res; + } +} + +export class RSACipher { + constructor(keyLength) { + this._key = null; + this._keyLength = keyLength; + this._keyBytes = Math.ceil(keyLength / 8); + this._n = null; + this._e = null; + this._d = null; + this._nBigInt = null; + this._eBigInt = null; + this._dBigInt = null; + } + + _base64urlDecode(data) { + data = data.replace(/-/g, "+").replace(/_/g, "/"); + data = data.padEnd(Math.ceil(data.length / 4) * 4, "="); + return Base64.decode(data); + } + + _u8ArrayToBigInt(arr) { + let hex = '0x'; + for (let i = 0; i < arr.length; i++) { + hex += arr[i].toString(16).padStart(2, '0'); + } + return BigInt(hex); + } + + _padArray(arr, length) { + const res = new Uint8Array(length); + res.set(arr, length - arr.length); + return res; + } + + _bigIntToU8Array(bigint, padLength=0) { + let hex = bigint.toString(16); + if (padLength === 0) { + padLength = Math.ceil(hex.length / 2) * 2; + } + hex = hex.padStart(padLength * 2, '0'); + const length = hex.length / 2; + const arr = new Uint8Array(length); + for (let i = 0; i < length; i++) { + arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return arr; + } + + _modPow(b, e, m) { + if (m === 1n) { + return 0; + } + let r = 1n; + b = b % m; + while (e > 0) { + if (e % 2n === 1n) { + r = (r * b) % m; + } + e = e / 2n; + b = (b * b) % m; + } + return r; + } + + async generateKey() { + this._key = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: this._keyLength, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: {name: "SHA-256"}, + }, + true, ["encrypt", "decrypt"]); + const privateKey = await window.crypto.subtle.exportKey("jwk", this._key.privateKey); + this._n = this._padArray(this._base64urlDecode(privateKey.n), this._keyBytes); + this._nBigInt = this._u8ArrayToBigInt(this._n); + this._e = this._padArray(this._base64urlDecode(privateKey.e), this._keyBytes); + this._eBigInt = this._u8ArrayToBigInt(this._e); + this._d = this._padArray(this._base64urlDecode(privateKey.d), this._keyBytes); + this._dBigInt = this._u8ArrayToBigInt(this._d); + } + + setPublicKey(n, e) { + if (n.length !== this._keyBytes || e.length !== this._keyBytes) { + return; + } + this._n = new Uint8Array(this._keyBytes); + this._e = new Uint8Array(this._keyBytes); + this._n.set(n); + this._e.set(e); + this._nBigInt = this._u8ArrayToBigInt(this._n); + this._eBigInt = this._u8ArrayToBigInt(this._e); + } + + encrypt(message) { + if (message.length > this._keyBytes - 11) { + return null; + } + const ps = new Uint8Array(this._keyBytes - message.length - 3); + window.crypto.getRandomValues(ps); + for (let i = 0; i < ps.length; i++) { + ps[i] = Math.floor(ps[i] * 254 / 255 + 1); + } + const em = new Uint8Array(this._keyBytes); + em[1] = 0x02; + em.set(ps, 2); + em.set(message, ps.length + 3); + const emBigInt = this._u8ArrayToBigInt(em); + const c = this._modPow(emBigInt, this._eBigInt, this._nBigInt); + return this._bigIntToU8Array(c, this._keyBytes); + } + + decrypt(message) { + if (message.length !== this._keyBytes) { + return null; + } + const msgBigInt = this._u8ArrayToBigInt(message); + const emBigInt = this._modPow(msgBigInt, this._dBigInt, this._nBigInt); + const em = this._bigIntToU8Array(emBigInt, this._keyBytes); + if (em[0] !== 0x00 || em[1] !== 0x02) { + return null; + } + let i = 2; + for (; i < em.length; i++) { + if (em[i] === 0x00) { + break; + } + } + if (i === em.length) { + return null; + } + return em.slice(i + 1, em.length); + } + + get keyLength() { + return this._keyLength; + } + + get n() { + return this._n; + } + + get e() { + return this._e; + } + + get d() { + return this._d; + } +} + +export default class RSAAESAuthenticationState extends EventTargetMixin { + constructor(sock, getCredentials) { + super(); + this._hasStarted = false; + this._checkSock = null; + this._checkCredentials = null; + this._approveServerResolve = null; + this._sockReject = null; + this._credentialsReject = null; + this._approveServerReject = null; + this._sock = sock; + this._getCredentials = getCredentials; + } + + _waitSockAsync(len) { + return new Promise((resolve, reject) => { + const hasData = () => !this._sock.rQwait('RA2', len); + if (hasData()) { + resolve(); + } else { + this._checkSock = () => { + if (hasData()) { + resolve(); + this._checkSock = null; + this._sockReject = null; + } + }; + this._sockReject = reject; + } + }); + } + + _waitApproveKeyAsync() { + return new Promise((resolve, reject) => { + this._approveServerResolve = resolve; + this._approveServerReject = reject; + }); + } + + _waitCredentialsAsync(subtype) { + const hasCredentials = () => { + if (subtype === 1 && this._getCredentials().username !== undefined && + this._getCredentials().password !== undefined) { + return true; + } else if (subtype === 2 && this._getCredentials().password !== undefined) { + return true; + } + return false; + }; + return new Promise((resolve, reject) => { + if (hasCredentials()) { + resolve(); + } else { + this._checkCredentials = () => { + if (hasCredentials()) { + resolve(); + this._checkCredentials = null; + this._credentialsReject = null; + } + }; + this._credentialsReject = reject; + } + }); + } + + checkInternalEvents() { + if (this._checkSock !== null) { + this._checkSock(); + } + if (this._checkCredentials !== null) { + this._checkCredentials(); + } + } + + approveServer() { + if (this._approveServerResolve !== null) { + this._approveServerResolve(); + this._approveServerResolve = null; + } + } + + disconnect() { + if (this._sockReject !== null) { + this._sockReject(new Error("disconnect normally")); + this._sockReject = null; + } + if (this._credentialsReject !== null) { + this._credentialsReject(new Error("disconnect normally")); + this._credentialsReject = null; + } + if (this._approveServerReject !== null) { + this._approveServerReject(new Error("disconnect normally")); + this._approveServerReject = null; + } + } + + async negotiateRA2neAuthAsync() { + this._hasStarted = true; + // 1: Receive server public key + await this._waitSockAsync(4); + const serverKeyLengthBuffer = this._sock.rQslice(0, 4); + const serverKeyLength = this._sock.rQshift32(); + if (serverKeyLength < 1024) { + throw new Error("RA2: server public key is too short: " + serverKeyLength); + } else if (serverKeyLength > 8192) { + throw new Error("RA2: server public key is too long: " + serverKeyLength); + } + const serverKeyBytes = Math.ceil(serverKeyLength / 8); + await this._waitSockAsync(serverKeyBytes * 2); + const serverN = this._sock.rQshiftBytes(serverKeyBytes); + const serverE = this._sock.rQshiftBytes(serverKeyBytes); + const serverRSACipher = new RSACipher(serverKeyLength); + serverRSACipher.setPublicKey(serverN, serverE); + const serverPublickey = new Uint8Array(4 + serverKeyBytes * 2); + serverPublickey.set(serverKeyLengthBuffer); + serverPublickey.set(serverN, 4); + serverPublickey.set(serverE, 4 + serverKeyBytes); + + // verify server public key + this.dispatchEvent(new CustomEvent("serververification", { + detail: { type: "RSA", publickey: serverPublickey } + })); + await this._waitApproveKeyAsync(); + + // 2: Send client public key + const clientKeyLength = 2048; + const clientKeyBytes = Math.ceil(clientKeyLength / 8); + const clientRSACipher = new RSACipher(clientKeyLength); + await clientRSACipher.generateKey(); + const clientN = clientRSACipher.n; + const clientE = clientRSACipher.e; + const clientPublicKey = new Uint8Array(4 + clientKeyBytes * 2); + clientPublicKey[0] = (clientKeyLength & 0xff000000) >>> 24; + clientPublicKey[1] = (clientKeyLength & 0xff0000) >>> 16; + clientPublicKey[2] = (clientKeyLength & 0xff00) >>> 8; + clientPublicKey[3] = clientKeyLength & 0xff; + clientPublicKey.set(clientN, 4); + clientPublicKey.set(clientE, 4 + clientKeyBytes); + this._sock.send(clientPublicKey); + + // 3: Send client random + const clientRandom = new Uint8Array(16); + window.crypto.getRandomValues(clientRandom); + const clientEncryptedRandom = serverRSACipher.encrypt(clientRandom); + const clientRandomMessage = new Uint8Array(2 + serverKeyBytes); + clientRandomMessage[0] = (serverKeyBytes & 0xff00) >>> 8; + clientRandomMessage[1] = serverKeyBytes & 0xff; + clientRandomMessage.set(clientEncryptedRandom, 2); + this._sock.send(clientRandomMessage); + + // 4: Receive server random + await this._waitSockAsync(2); + if (this._sock.rQshift16() !== clientKeyBytes) { + throw new Error("RA2: wrong encrypted message length"); + } + const serverEncryptedRandom = this._sock.rQshiftBytes(clientKeyBytes); + const serverRandom = clientRSACipher.decrypt(serverEncryptedRandom); + if (serverRandom === null || serverRandom.length !== 16) { + throw new Error("RA2: corrupted server encrypted random"); + } + + // 5: Compute session keys and set ciphers + let clientSessionKey = new Uint8Array(32); + let serverSessionKey = new Uint8Array(32); + clientSessionKey.set(serverRandom); + clientSessionKey.set(clientRandom, 16); + serverSessionKey.set(clientRandom); + serverSessionKey.set(serverRandom, 16); + clientSessionKey = await window.crypto.subtle.digest("SHA-1", clientSessionKey); + clientSessionKey = new Uint8Array(clientSessionKey).slice(0, 16); + serverSessionKey = await window.crypto.subtle.digest("SHA-1", serverSessionKey); + serverSessionKey = new Uint8Array(serverSessionKey).slice(0, 16); + const clientCipher = new RA2Cipher(); + await clientCipher.setKey(clientSessionKey); + const serverCipher = new RA2Cipher(); + await serverCipher.setKey(serverSessionKey); + + // 6: Compute and exchange hashes + let serverHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2); + let clientHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2); + serverHash.set(serverPublickey); + serverHash.set(clientPublicKey, 4 + serverKeyBytes * 2); + clientHash.set(clientPublicKey); + clientHash.set(serverPublickey, 4 + clientKeyBytes * 2); + serverHash = await window.crypto.subtle.digest("SHA-1", serverHash); + clientHash = await window.crypto.subtle.digest("SHA-1", clientHash); + serverHash = new Uint8Array(serverHash); + clientHash = new Uint8Array(clientHash); + this._sock.send(await clientCipher.makeMessage(clientHash)); + await this._waitSockAsync(2 + 20 + 16); + if (this._sock.rQshift16() !== 20) { + throw new Error("RA2: wrong server hash"); + } + const serverHashReceived = await serverCipher.receiveMessage( + 20, this._sock.rQshiftBytes(20), this._sock.rQshiftBytes(16)); + if (serverHashReceived === null) { + throw new Error("RA2: failed to authenticate the message"); + } + for (let i = 0; i < 20; i++) { + if (serverHashReceived[i] !== serverHash[i]) { + throw new Error("RA2: wrong server hash"); + } + } + + // 7: Receive subtype + await this._waitSockAsync(2 + 1 + 16); + if (this._sock.rQshift16() !== 1) { + throw new Error("RA2: wrong subtype"); + } + let subtype = (await serverCipher.receiveMessage( + 1, this._sock.rQshiftBytes(1), this._sock.rQshiftBytes(16))); + if (subtype === null) { + throw new Error("RA2: failed to authenticate the message"); + } + subtype = subtype[0]; + if (subtype === 1) { + if (this._getCredentials().username === undefined || + this._getCredentials().password === undefined) { + this.dispatchEvent(new CustomEvent( + "credentialsrequired", + { detail: { types: ["username", "password"] } })); + } + } else if (subtype === 2) { + if (this._getCredentials().password === undefined) { + this.dispatchEvent(new CustomEvent( + "credentialsrequired", + { detail: { types: ["password"] } })); + } + } else { + throw new Error("RA2: wrong subtype"); + } + await this._waitCredentialsAsync(subtype); + let username; + if (subtype === 1) { + username = encodeUTF8(this._getCredentials().username).slice(0, 255); + } else { + username = ""; + } + const password = encodeUTF8(this._getCredentials().password).slice(0, 255); + const credentials = new Uint8Array(username.length + password.length + 2); + credentials[0] = username.length; + credentials[username.length + 1] = password.length; + for (let i = 0; i < username.length; i++) { + credentials[i + 1] = username.charCodeAt(i); + } + for (let i = 0; i < password.length; i++) { + credentials[username.length + 2 + i] = password.charCodeAt(i); + } + this._sock.send(await clientCipher.makeMessage(credentials)); + } + + get hasStarted() { + return this._hasStarted; + } + + set hasStarted(s) { + this._hasStarted = s; + } +} \ No newline at end of file diff --git a/public/novnc/core/rfb.js b/public/novnc/core/rfb.js index ea3bf58a..6afd7c65 100644 --- a/public/novnc/core/rfb.js +++ b/public/novnc/core/rfb.js @@ -25,6 +25,8 @@ import DES from "./des.js"; import KeyTable from "./input/keysym.js"; import XtScancode from "./input/xtscancodes.js"; import { encodings } from "./encodings.js"; +import RSAAESAuthenticationState from "./ra2.js"; +import { MD5 } from "./util/md5.js"; import RawDecoder from "./decoders/raw.js"; import CopyRectDecoder from "./decoders/copyrect.js"; @@ -32,6 +34,8 @@ import RREDecoder from "./decoders/rre.js"; import HextileDecoder from "./decoders/hextile.js"; import TightDecoder from "./decoders/tight.js"; import TightPNGDecoder from "./decoders/tightpng.js"; +import ZRLEDecoder from "./decoders/zrle.js"; +import JPEGDecoder from "./decoders/jpeg.js"; // How many seconds to wait for a disconnect to finish const DISCONNECT_TIMEOUT = 3; @@ -50,6 +54,22 @@ const GESTURE_SCRLSENS = 50; const DOUBLE_TAP_TIMEOUT = 1000; const DOUBLE_TAP_THRESHOLD = 50; +// Security types +const securityTypeNone = 1; +const securityTypeVNCAuth = 2; +const securityTypeRA2ne = 6; +const securityTypeTight = 16; +const securityTypeVeNCrypt = 19; +const securityTypeXVP = 22; +const securityTypeARD = 30; +const securityTypeMSLogonII = 113; + +// Special Tight security types +const securityTypeUnixLogon = 129; + +// VeNCrypt security types +const securityTypePlain = 256; + // Extended clipboard pseudo-encoding formats const extendedClipboardFormatText = 1; /*eslint-disable no-unused-vars */ @@ -75,6 +95,12 @@ export default class RFB extends EventTargetMixin { throw new Error("Must specify URL, WebSocket or RTCDataChannel"); } + // We rely on modern APIs which might not be available in an + // insecure context + if (!window.isSecureContext) { + Log.Error("noVNC requires a secure context (TLS). Expect crashes!"); + } + super(); this._target = target; @@ -98,6 +124,7 @@ export default class RFB extends EventTargetMixin { this._rfbInitState = ''; this._rfbAuthScheme = -1; this._rfbCleanDisconnect = true; + this._rfbRSAAESAuthenticationState = null; // Server capabilities this._rfbVersion = 0; @@ -176,6 +203,8 @@ export default class RFB extends EventTargetMixin { handleMouse: this._handleMouse.bind(this), handleWheel: this._handleWheel.bind(this), handleGesture: this._handleGesture.bind(this), + handleRSAAESCredentialsRequired: this._handleRSAAESCredentialsRequired.bind(this), + handleRSAAESServerVerification: this._handleRSAAESServerVerification.bind(this), }; // main setup @@ -218,6 +247,8 @@ export default class RFB extends EventTargetMixin { this._decoders[encodings.encodingHextile] = new HextileDecoder(); this._decoders[encodings.encodingTight] = new TightDecoder(); this._decoders[encodings.encodingTightPNG] = new TightPNGDecoder(); + this._decoders[encodings.encodingZRLE] = new ZRLEDecoder(); + this._decoders[encodings.encodingJPEG] = new JPEGDecoder(); // NB: nothing that needs explicit teardown should be done // before this point, since this can throw an exception @@ -240,6 +271,8 @@ export default class RFB extends EventTargetMixin { this._sock.on('message', this._handleMessage.bind(this)); this._sock.on('error', this._socketError.bind(this)); + this._expectedClientWidth = null; + this._expectedClientHeight = null; this._resizeObserver = new ResizeObserver(this._eventHandlers.handleResize); // All prepared, kick off the connection @@ -254,6 +287,7 @@ export default class RFB extends EventTargetMixin { this._viewOnly = false; this._clipViewport = false; + this._clippingViewport = false; this._scaleViewport = false; this._resizeSession = false; @@ -285,6 +319,16 @@ export default class RFB extends EventTargetMixin { get capabilities() { return this._capabilities; } + get clippingViewport() { return this._clippingViewport; } + _setClippingViewport(on) { + if (on === this._clippingViewport) { + return; + } + this._clippingViewport = on; + this.dispatchEvent(new CustomEvent("clippingviewport", + { detail: this._clippingViewport })); + } + get touchButton() { return 0; } set touchButton(button) { Log.Warn("Using old API!"); } @@ -372,11 +416,20 @@ export default class RFB extends EventTargetMixin { this._sock.off('error'); this._sock.off('message'); this._sock.off('open'); + if (this._rfbRSAAESAuthenticationState !== null) { + this._rfbRSAAESAuthenticationState.disconnect(); + } + } + + approveServer() { + if (this._rfbRSAAESAuthenticationState !== null) { + this._rfbRSAAESAuthenticationState.approveServer(); + } } sendCredentials(creds) { this._rfbCredentials = creds; - setTimeout(this._initMsg.bind(this), 0); + this._resumeAuthentication(); } sendCtrlAltDel() { @@ -432,8 +485,8 @@ export default class RFB extends EventTargetMixin { } } - focus() { - this._canvas.focus(); + focus(options) { + this._canvas.focus(options); } blur() { @@ -449,16 +502,45 @@ export default class RFB extends EventTargetMixin { this._clipboardText = text; RFB.messages.extendedClipboardNotify(this._sock, [extendedClipboardFormatText]); } else { - let data = new Uint8Array(text.length); - for (let i = 0; i < text.length; i++) { - // FIXME: text can have values outside of Latin1/Uint8 - data[i] = text.charCodeAt(i); + let length, i; + let data; + + length = 0; + // eslint-disable-next-line no-unused-vars + for (let codePoint of text) { + length++; + } + + data = new Uint8Array(length); + + i = 0; + for (let codePoint of text) { + let code = codePoint.codePointAt(0); + + /* Only ISO 8859-1 is supported */ + if (code > 0xff) { + code = 0x3f; // '?' + } + + data[i++] = code; } RFB.messages.clientCutText(this._sock, data); } } + getImageData() { + return this._display.getImageData(); + } + + toDataURL(type, encoderOptions) { + return this._display.toDataURL(type, encoderOptions); + } + + toBlob(callback, type, quality) { + return this._display.toBlob(callback, type, quality); + } + // ===== PRIVATE METHODS ===== _connect() { @@ -609,7 +691,7 @@ export default class RFB extends EventTargetMixin { return; } - this.focus(); + this.focus({ preventScroll: true }); } _setDesktopName(name) { @@ -619,7 +701,26 @@ export default class RFB extends EventTargetMixin { { detail: { name: this._fbName } })); } + _saveExpectedClientSize() { + this._expectedClientWidth = this._screen.clientWidth; + this._expectedClientHeight = this._screen.clientHeight; + } + + _currentClientSize() { + return [this._screen.clientWidth, this._screen.clientHeight]; + } + + _clientHasExpectedSize() { + const [currentWidth, currentHeight] = this._currentClientSize(); + return currentWidth == this._expectedClientWidth && + currentHeight == this._expectedClientHeight; + } + _handleResize() { + // Don't change anything if the client size is already as expected + if (this._clientHasExpectedSize()) { + return; + } // If the window resized then our screen element might have // as well. Update the viewport dimensions. window.requestAnimationFrame(() => { @@ -659,6 +760,16 @@ export default class RFB extends EventTargetMixin { const size = this._screenSize(); this._display.viewportChangeSize(size.w, size.h); this._fixScrollbars(); + this._setClippingViewport(size.w < this._display.width || + size.h < this._display.height); + } else { + this._setClippingViewport(false); + } + + // When changing clipping we might show or hide scrollbars. + // This causes the expected client dimensions to change. + if (curClip !== newClip) { + this._saveExpectedClientSize(); } } @@ -684,6 +795,7 @@ export default class RFB extends EventTargetMixin { } const size = this._screenSize(); + RFB.messages.setDesktopSize(this._sock, Math.floor(size.w), Math.floor(size.h), this._screenID, this._screenFlags); @@ -699,12 +811,13 @@ export default class RFB extends EventTargetMixin { } _fixScrollbars() { - // This is a hack because Chrome screws up the calculation - // for when scrollbars are needed. So to fix it we temporarily - // toggle them off and on. + // This is a hack because Safari on macOS screws up the calculation + // for when scrollbars are needed. We get scrollbars when making the + // browser smaller, despite remote resize being enabled. So to fix it + // we temporarily toggle them off and on. const orig = this._screen.style.overflow; this._screen.style.overflow = 'hidden'; - // Force Chrome to recalculate the layout by asking for + // Force Safari to recalculate the layout by asking for // an element's dimensions this._screen.getBoundingClientRect(); this._screen.style.overflow = orig; @@ -869,8 +982,15 @@ export default class RFB extends EventTargetMixin { } } break; + case 'connecting': + while (this._rfbConnectionState === 'connecting') { + if (!this._initMsg()) { + break; + } + } + break; default: - this._initMsg(); + Log.Error("Got data while in an invalid state"); break; } } @@ -1242,13 +1362,13 @@ export default class RFB extends EventTargetMixin { break; case "003.003": case "003.006": // UltraVNC - case "003.889": // Apple Remote Desktop this._rfbVersion = 3.3; break; case "003.007": this._rfbVersion = 3.7; break; case "003.008": + case "003.889": // Apple Remote Desktop case "004.000": // Intel AMT KVM case "004.001": // RealVNC 4.6 case "005.000": // RealVNC 5.3 @@ -1279,6 +1399,22 @@ export default class RFB extends EventTargetMixin { this._rfbInitState = 'Security'; } + _isSupportedSecurityType(type) { + const clientTypes = [ + securityTypeNone, + securityTypeVNCAuth, + securityTypeRA2ne, + securityTypeTight, + securityTypeVeNCrypt, + securityTypeXVP, + securityTypeARD, + securityTypeMSLogonII, + securityTypePlain, + ]; + + return clientTypes.includes(type); + } + _negotiateSecurity() { if (this._rfbVersion >= 3.7) { // Server sends supported list, client decides @@ -1289,24 +1425,23 @@ export default class RFB extends EventTargetMixin { this._rfbInitState = "SecurityReason"; this._securityContext = "no security types"; this._securityStatus = 1; - return this._initMsg(); + return true; } const types = this._sock.rQshiftBytes(numTypes); Log.Debug("Server security types: " + types); - // Look for each auth in preferred order - if (types.includes(1)) { - this._rfbAuthScheme = 1; // None - } else if (types.includes(22)) { - this._rfbAuthScheme = 22; // XVP - } else if (types.includes(16)) { - this._rfbAuthScheme = 16; // Tight - } else if (types.includes(2)) { - this._rfbAuthScheme = 2; // VNC Auth - } else if (types.includes(19)) { - this._rfbAuthScheme = 19; // VeNCrypt Auth - } else { + // Look for a matching security type in the order that the + // server prefers + this._rfbAuthScheme = -1; + for (let type of types) { + if (this._isSupportedSecurityType(type)) { + this._rfbAuthScheme = type; + break; + } + } + + if (this._rfbAuthScheme === -1) { return this._fail("Unsupported security types (types: " + types + ")"); } @@ -1320,14 +1455,14 @@ export default class RFB extends EventTargetMixin { this._rfbInitState = "SecurityReason"; this._securityContext = "authentication scheme"; this._securityStatus = 1; - return this._initMsg(); + return true; } } this._rfbInitState = 'Authentication'; Log.Debug('Authenticating using scheme: ' + this._rfbAuthScheme); - return this._initMsg(); // jump to authentication + return true; } _handleSecurityReason() { @@ -1377,7 +1512,7 @@ export default class RFB extends EventTargetMixin { this._rfbCredentials.username + this._rfbCredentials.target; this._sock.sendString(xvpAuthStr); - this._rfbAuthScheme = 2; + this._rfbAuthScheme = securityTypeVNCAuth; return this._negotiateAuthentication(); } @@ -1435,49 +1570,66 @@ export default class RFB extends EventTargetMixin { subtypes.push(this._sock.rQshift32()); } - // 256 = Plain subtype - if (subtypes.indexOf(256) != -1) { - // 0x100 = 256 - this._sock.send([0, 0, 1, 0]); - this._rfbVeNCryptState = 4; - } else { - return this._fail("VeNCrypt Plain subtype not offered by server"); - } - } + // Look for a matching security type in the order that the + // server prefers + this._rfbAuthScheme = -1; + for (let type of subtypes) { + // Avoid getting in to a loop + if (type === securityTypeVeNCrypt) { + continue; + } - // negotiated Plain subtype, server waits for password - if (this._rfbVeNCryptState == 4) { - if (this._rfbCredentials.username === undefined || - this._rfbCredentials.password === undefined) { - this.dispatchEvent(new CustomEvent( - "credentialsrequired", - { detail: { types: ["username", "password"] } })); - return false; + if (this._isSupportedSecurityType(type)) { + this._rfbAuthScheme = type; + break; + } } - const user = encodeUTF8(this._rfbCredentials.username); - const pass = encodeUTF8(this._rfbCredentials.password); + if (this._rfbAuthScheme === -1) { + return this._fail("Unsupported security types (types: " + subtypes + ")"); + } - this._sock.send([ - (user.length >> 24) & 0xFF, - (user.length >> 16) & 0xFF, - (user.length >> 8) & 0xFF, - user.length & 0xFF - ]); - this._sock.send([ - (pass.length >> 24) & 0xFF, - (pass.length >> 16) & 0xFF, - (pass.length >> 8) & 0xFF, - pass.length & 0xFF - ]); - this._sock.sendString(user); - this._sock.sendString(pass); + this._sock.send([this._rfbAuthScheme >> 24, + this._rfbAuthScheme >> 16, + this._rfbAuthScheme >> 8, + this._rfbAuthScheme]); - this._rfbInitState = "SecurityResult"; + this._rfbVeNCryptState == 4; return true; } } + _negotiatePlainAuth() { + if (this._rfbCredentials.username === undefined || + this._rfbCredentials.password === undefined) { + this.dispatchEvent(new CustomEvent( + "credentialsrequired", + { detail: { types: ["username", "password"] } })); + return false; + } + + const user = encodeUTF8(this._rfbCredentials.username); + const pass = encodeUTF8(this._rfbCredentials.password); + + this._sock.send([ + (user.length >> 24) & 0xFF, + (user.length >> 16) & 0xFF, + (user.length >> 8) & 0xFF, + user.length & 0xFF + ]); + this._sock.send([ + (pass.length >> 24) & 0xFF, + (pass.length >> 16) & 0xFF, + (pass.length >> 8) & 0xFF, + pass.length & 0xFF + ]); + this._sock.sendString(user); + this._sock.sendString(pass); + + this._rfbInitState = "SecurityResult"; + return true; + } + _negotiateStdVNCAuth() { if (this._sock.rQwait("auth challenge", 16)) { return false; } @@ -1496,6 +1648,117 @@ export default class RFB extends EventTargetMixin { return true; } + _negotiateARDAuth() { + + if (this._rfbCredentials.username === undefined || + this._rfbCredentials.password === undefined) { + this.dispatchEvent(new CustomEvent( + "credentialsrequired", + { detail: { types: ["username", "password"] } })); + return false; + } + + if (this._rfbCredentials.ardPublicKey != undefined && + this._rfbCredentials.ardCredentials != undefined) { + // if the async web crypto is done return the results + this._sock.send(this._rfbCredentials.ardCredentials); + this._sock.send(this._rfbCredentials.ardPublicKey); + this._rfbCredentials.ardCredentials = null; + this._rfbCredentials.ardPublicKey = null; + this._rfbInitState = "SecurityResult"; + return true; + } + + if (this._sock.rQwait("read ard", 4)) { return false; } + + let generator = this._sock.rQshiftBytes(2); // DH base generator value + + let keyLength = this._sock.rQshift16(); + + if (this._sock.rQwait("read ard keylength", keyLength*2, 4)) { return false; } + + // read the server values + let prime = this._sock.rQshiftBytes(keyLength); // predetermined prime modulus + let serverPublicKey = this._sock.rQshiftBytes(keyLength); // other party's public key + + let clientPrivateKey = window.crypto.getRandomValues(new Uint8Array(keyLength)); + let padding = Array.from(window.crypto.getRandomValues(new Uint8Array(64)), byte => String.fromCharCode(65+byte%26)).join(''); + + this._negotiateARDAuthAsync(generator, keyLength, prime, serverPublicKey, clientPrivateKey, padding); + + return false; + } + + _modPow(base, exponent, modulus) { + + let baseHex = "0x"+Array.from(base, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join(''); + let exponentHex = "0x"+Array.from(exponent, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join(''); + let modulusHex = "0x"+Array.from(modulus, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join(''); + + let b = BigInt(baseHex); + let e = BigInt(exponentHex); + let m = BigInt(modulusHex); + let r = 1n; + b = b % m; + while (e > 0) { + if (e % 2n === 1n) { + r = (r * b) % m; + } + e = e / 2n; + b = (b * b) % m; + } + let hexResult = r.toString(16); + + while (hexResult.length/2 String.fromCharCode(byte)).join(''); + let aesKey = await window.crypto.subtle.importKey("raw", MD5(keyString), {name: "AES-CBC"}, false, ["encrypt"]); + let data = new Uint8Array(string.length); + for (let i = 0; i < string.length; ++i) { + data[i] = string.charCodeAt(i); + } + let encrypted = new Uint8Array(data.length); + for (let i=0;i this._rfbCredentials); + this._rfbRSAAESAuthenticationState.addEventListener( + "serververification", this._eventHandlers.handleRSAAESServerVerification); + this._rfbRSAAESAuthenticationState.addEventListener( + "credentialsrequired", this._eventHandlers.handleRSAAESCredentialsRequired); + } + this._rfbRSAAESAuthenticationState.checkInternalEvents(); + if (!this._rfbRSAAESAuthenticationState.hasStarted) { + this._rfbRSAAESAuthenticationState.negotiateRA2neAuthAsync() + .catch((e) => { + if (e.message !== "disconnect normally") { + this._fail(e.message); + } + }).then(() => { + this.dispatchEvent(new CustomEvent('securityresult')); + this._rfbInitState = "SecurityResult"; + return true; + }).finally(() => { + this._rfbRSAAESAuthenticationState.removeEventListener( + "serververification", this._eventHandlers.handleRSAAESServerVerification); + this._rfbRSAAESAuthenticationState.removeEventListener( + "credentialsrequired", this._eventHandlers.handleRSAAESCredentialsRequired); + this._rfbRSAAESAuthenticationState = null; + }); + } + return false; + } + + _negotiateMSLogonIIAuth() { + if (this._sock.rQwait("mslogonii dh param", 24)) { return false; } + + if (this._rfbCredentials.username === undefined || + this._rfbCredentials.password === undefined) { + this.dispatchEvent(new CustomEvent( + "credentialsrequired", + { detail: { types: ["username", "password"] } })); + return false; + } + + const g = this._sock.rQshiftBytes(8); + const p = this._sock.rQshiftBytes(8); + const A = this._sock.rQshiftBytes(8); + const b = window.crypto.getRandomValues(new Uint8Array(8)); + const B = new Uint8Array(this._modPow(g, b, p)); + const secret = new Uint8Array(this._modPow(A, b, p)); + + const des = new DES(secret); + const username = encodeUTF8(this._rfbCredentials.username).substring(0, 255); + const password = encodeUTF8(this._rfbCredentials.password).substring(0, 63); + const usernameBytes = new Uint8Array(256); + const passwordBytes = new Uint8Array(64); + window.crypto.getRandomValues(usernameBytes); + window.crypto.getRandomValues(passwordBytes); + for (let i = 0; i < username.length; i++) { + usernameBytes[i] = username.charCodeAt(i); + } + usernameBytes[username.length] = 0; + for (let i = 0; i < password.length; i++) { + passwordBytes[i] = password.charCodeAt(i); + } + passwordBytes[password.length] = 0; + let x = new Uint8Array(secret); + for (let i = 0; i < 32; i++) { + for (let j = 0; j < 8; j++) { + x[j] ^= usernameBytes[i * 8 + j]; + } + x = des.enc8(x); + usernameBytes.set(x, i * 8); + } + x = new Uint8Array(secret); + for (let i = 0; i < 8; i++) { + for (let j = 0; j < 8; j++) { + x[j] ^= passwordBytes[i * 8 + j]; + } + x = des.enc8(x); + passwordBytes.set(x, i * 8); + } + this._sock.send(B); + this._sock.send(usernameBytes); + this._sock.send(passwordBytes); + this._rfbInitState = "SecurityResult"; + return true; + } + _negotiateAuthentication() { switch (this._rfbAuthScheme) { - case 1: // no auth - if (this._rfbVersion >= 3.8) { - this._rfbInitState = 'SecurityResult'; - return true; - } - this._rfbInitState = 'ClientInitialisation'; - return this._initMsg(); + case securityTypeNone: + this._rfbInitState = 'SecurityResult'; + return true; - case 22: // XVP auth + case securityTypeXVP: return this._negotiateXvpAuth(); - case 2: // VNC authentication + case securityTypeARD: + return this._negotiateARDAuth(); + + case securityTypeVNCAuth: return this._negotiateStdVNCAuth(); - case 16: // TightVNC Security Type + case securityTypeTight: return this._negotiateTightAuth(); - case 19: // VeNCrypt Security Type + case securityTypeVeNCrypt: return this._negotiateVeNCryptAuth(); - case 129: // TightVNC UNIX Security Type + case securityTypePlain: + return this._negotiatePlainAuth(); + + case securityTypeUnixLogon: return this._negotiateTightUnixAuth(); + case securityTypeRA2ne: + return this._negotiateRA2neAuth(); + + case securityTypeMSLogonII: + return this._negotiateMSLogonIIAuth(); + default: return this._fail("Unsupported auth scheme (scheme: " + this._rfbAuthScheme + ")"); @@ -1651,6 +2016,13 @@ export default class RFB extends EventTargetMixin { } _handleSecurityResult() { + // There is no security choice, and hence no security result + // until RFB 3.7 + if (this._rfbVersion < 3.7) { + this._rfbInitState = 'ClientInitialisation'; + return true; + } + if (this._sock.rQwait('VNC auth response ', 4)) { return false; } const status = this._sock.rQshift32(); @@ -1658,13 +2030,13 @@ export default class RFB extends EventTargetMixin { if (status === 0) { // OK this._rfbInitState = 'ClientInitialisation'; Log.Debug('Authentication OK'); - return this._initMsg(); + return true; } else { if (this._rfbVersion >= 3.8) { this._rfbInitState = "SecurityReason"; this._securityContext = "security result"; this._securityStatus = status; - return this._initMsg(); + return true; } else { this.dispatchEvent(new CustomEvent( "securityfailure", @@ -1772,6 +2144,8 @@ export default class RFB extends EventTargetMixin { if (this._fbDepth == 24) { encs.push(encodings.encodingTight); encs.push(encodings.encodingTightPNG); + encs.push(encodings.encodingZRLE); + encs.push(encodings.encodingJPEG); encs.push(encodings.encodingHextile); encs.push(encodings.encodingRRE); } @@ -1838,6 +2212,14 @@ export default class RFB extends EventTargetMixin { } } + // Resume authentication handshake after it was paused for some + // reason, e.g. waiting for a password from the user + _resumeAuthentication() { + // We use setTimeout() so it's run in its own context, just like + // it originally did via the WebSocket's event handler + setTimeout(this._initMsg.bind(this), 0); + } + _handleSetColourMapMsg() { Log.Debug("SetColorMapEntries"); @@ -2500,6 +2882,9 @@ export default class RFB extends EventTargetMixin { this._updateScale(); this._updateContinuousUpdates(); + + // Keep this size until browser client size changes + this._saveExpectedClientSize(); } _xvpOp(ver, op) { diff --git a/public/novnc/core/util/browser.js b/public/novnc/core/util/browser.js index 24b5e960..bbc9f5c1 100644 --- a/public/novnc/core/util/browser.js +++ b/public/novnc/core/util/browser.js @@ -77,27 +77,76 @@ export const hasScrollbarGutter = _hasScrollbarGutter; * It's better to use feature detection than platform detection. */ +/* OS */ + export function isMac() { - return navigator && !!(/mac/i).exec(navigator.platform); + return !!(/mac/i).exec(navigator.platform); } export function isWindows() { - return navigator && !!(/win/i).exec(navigator.platform); + return !!(/win/i).exec(navigator.platform); } export function isIOS() { - return navigator && - (!!(/ipad/i).exec(navigator.platform) || + return (!!(/ipad/i).exec(navigator.platform) || !!(/iphone/i).exec(navigator.platform) || !!(/ipod/i).exec(navigator.platform)); } +export function isAndroid() { + /* Android sets navigator.platform to Linux :/ */ + return !!navigator.userAgent.match('Android '); +} + +export function isChromeOS() { + /* ChromeOS sets navigator.platform to Linux :/ */ + return !!navigator.userAgent.match(' CrOS '); +} + +/* Browser */ + export function isSafari() { - return navigator && (navigator.userAgent.indexOf('Safari') !== -1 && - navigator.userAgent.indexOf('Chrome') === -1); + return !!navigator.userAgent.match('Safari/...') && + !navigator.userAgent.match('Chrome/...') && + !navigator.userAgent.match('Chromium/...') && + !navigator.userAgent.match('Epiphany/...'); } export function isFirefox() { - return navigator && !!(/firefox/i).exec(navigator.userAgent); + return !!navigator.userAgent.match('Firefox/...') && + !navigator.userAgent.match('Seamonkey/...'); } +export function isChrome() { + return !!navigator.userAgent.match('Chrome/...') && + !navigator.userAgent.match('Chromium/...') && + !navigator.userAgent.match('Edg/...') && + !navigator.userAgent.match('OPR/...'); +} + +export function isChromium() { + return !!navigator.userAgent.match('Chromium/...'); +} + +export function isOpera() { + return !!navigator.userAgent.match('OPR/...'); +} + +export function isEdge() { + return !!navigator.userAgent.match('Edg/...'); +} + +/* Engine */ + +export function isGecko() { + return !!navigator.userAgent.match('Gecko/...'); +} + +export function isWebKit() { + return !!navigator.userAgent.match('AppleWebKit/...') && + !navigator.userAgent.match('Chrome/...'); +} + +export function isBlink() { + return !!navigator.userAgent.match('Chrome/...'); +} diff --git a/public/novnc/core/util/cursor.js b/public/novnc/core/util/cursor.js index 12bcceda..3000cf0e 100644 --- a/public/novnc/core/util/cursor.js +++ b/public/novnc/core/util/cursor.js @@ -18,6 +18,10 @@ export default class Cursor { this._canvas.style.position = 'fixed'; this._canvas.style.zIndex = '65535'; this._canvas.style.pointerEvents = 'none'; + // Safari on iOS can select the cursor image + // https://bugs.webkit.org/show_bug.cgi?id=249223 + this._canvas.style.userSelect = 'none'; + this._canvas.style.WebkitUserSelect = 'none'; // Can't use "display" because of Firefox bug #1445997 this._canvas.style.visibility = 'hidden'; } diff --git a/public/novnc/core/util/md5.js b/public/novnc/core/util/md5.js new file mode 100644 index 00000000..49762ef9 --- /dev/null +++ b/public/novnc/core/util/md5.js @@ -0,0 +1,79 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2021 The noVNC Authors + * Licensed under MPL 2.0 (see LICENSE.txt) + * + * See README.md for usage and integration instructions. + */ + +/* + * Performs MD5 hashing on a string of binary characters, returns an array of bytes + */ + +export function MD5(d) { + let r = M(V(Y(X(d), 8 * d.length))); + return r; +} + +function M(d) { + let f = new Uint8Array(d.length); + for (let i=0;i> 2); + for (let m = 0; m < r.length; m++) r[m] = 0; + for (let m = 0; m < 8 * d.length; m += 8) r[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32; + return r; +} + +function V(d) { + let r = ""; + for (let m = 0; m < 32 * d.length; m += 8) r += String.fromCharCode(d[m >> 5] >>> m % 32 & 255); + return r; +} + +function Y(d, g) { + d[g >> 5] |= 128 << g % 32, d[14 + (g + 64 >>> 9 << 4)] = g; + let m = 1732584193, f = -271733879, r = -1732584194, i = 271733878; + for (let n = 0; n < d.length; n += 16) { + let h = m, + t = f, + g = r, + e = i; + f = ii(f = ii(f = ii(f = ii(f = hh(f = hh(f = hh(f = hh(f = gg(f = gg(f = gg(f = gg(f = ff(f = ff(f = ff(f = ff(f, r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 0], 7, -680876936), f, r, d[n + 1], 12, -389564586), m, f, d[n + 2], 17, 606105819), i, m, d[n + 3], 22, -1044525330), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 4], 7, -176418897), f, r, d[n + 5], 12, 1200080426), m, f, d[n + 6], 17, -1473231341), i, m, d[n + 7], 22, -45705983), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 8], 7, 1770035416), f, r, d[n + 9], 12, -1958414417), m, f, d[n + 10], 17, -42063), i, m, d[n + 11], 22, -1990404162), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 12], 7, 1804603682), f, r, d[n + 13], 12, -40341101), m, f, d[n + 14], 17, -1502002290), i, m, d[n + 15], 22, 1236535329), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 1], 5, -165796510), f, r, d[n + 6], 9, -1069501632), m, f, d[n + 11], 14, 643717713), i, m, d[n + 0], 20, -373897302), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 5], 5, -701558691), f, r, d[n + 10], 9, 38016083), m, f, d[n + 15], 14, -660478335), i, m, d[n + 4], 20, -405537848), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 9], 5, 568446438), f, r, d[n + 14], 9, -1019803690), m, f, d[n + 3], 14, -187363961), i, m, d[n + 8], 20, 1163531501), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 13], 5, -1444681467), f, r, d[n + 2], 9, -51403784), m, f, d[n + 7], 14, 1735328473), i, m, d[n + 12], 20, -1926607734), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 5], 4, -378558), f, r, d[n + 8], 11, -2022574463), m, f, d[n + 11], 16, 1839030562), i, m, d[n + 14], 23, -35309556), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 1], 4, -1530992060), f, r, d[n + 4], 11, 1272893353), m, f, d[n + 7], 16, -155497632), i, m, d[n + 10], 23, -1094730640), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 13], 4, 681279174), f, r, d[n + 0], 11, -358537222), m, f, d[n + 3], 16, -722521979), i, m, d[n + 6], 23, 76029189), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 9], 4, -640364487), f, r, d[n + 12], 11, -421815835), m, f, d[n + 15], 16, 530742520), i, m, d[n + 2], 23, -995338651), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 0], 6, -198630844), f, r, d[n + 7], 10, 1126891415), m, f, d[n + 14], 15, -1416354905), i, m, d[n + 5], 21, -57434055), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 12], 6, 1700485571), f, r, d[n + 3], 10, -1894986606), m, f, d[n + 10], 15, -1051523), i, m, d[n + 1], 21, -2054922799), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 8], 6, 1873313359), f, r, d[n + 15], 10, -30611744), m, f, d[n + 6], 15, -1560198380), i, m, d[n + 13], 21, 1309151649), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 4], 6, -145523070), f, r, d[n + 11], 10, -1120210379), m, f, d[n + 2], 15, 718787259), i, m, d[n + 9], 21, -343485551), m = add(m, h), f = add(f, t), r = add(r, g), i = add(i, e); + } + return Array(m, f, r, i); +} + +function cmn(d, g, m, f, r, i) { + return add(rol(add(add(g, d), add(f, i)), r), m); +} + +function ff(d, g, m, f, r, i, n) { + return cmn(g & m | ~g & f, d, g, r, i, n); +} + +function gg(d, g, m, f, r, i, n) { + return cmn(g & f | m & ~f, d, g, r, i, n); +} + +function hh(d, g, m, f, r, i, n) { + return cmn(g ^ m ^ f, d, g, r, i, n); +} + +function ii(d, g, m, f, r, i, n) { + return cmn(m ^ (g | ~f), d, g, r, i, n); +} + +function add(d, g) { + let m = (65535 & d) + (65535 & g); + return (d >> 16) + (g >> 16) + (m >> 16) << 16 | 65535 & m; +} + +function rol(d, g) { + return d << g | d >>> 32 - g; +} \ No newline at end of file diff --git a/public/novnc/vnc.html b/public/novnc/vnc.html index 4d23ddd6..a391a1f4 100644 --- a/public/novnc/vnc.html +++ b/public/novnc/vnc.html @@ -1,4 +1,4 @@ - + @@ -15,53 +15,35 @@ --> noVNC - - - - - - - + - - + + + + - + @@ -83,7 +65,9 @@ - + + + Clipboard + + Edit clipboard content in the textarea below. + - - - + title="Full Screen"> + + Settings + - - Settings - Shared Mode @@ -263,39 +247,69 @@ - - + + + + + - - - Connect - + + + + Connect + + + + + + + Server identity + + + The server has provided the following identifying information: + + + Fingerprint: + + + + Please verify that the information is correct and press + "Approve". Otherwise press "Reject". + + + + + + + + - - - Username: - - - - Password: - - - - - - + + Credentials + + + Username: + + + + Password: + + + + +
+ Edit clipboard content in the textarea below. +