From 0532471759beff601f79607ff06d472342cf814f Mon Sep 17 00:00:00 2001 From: catborise Date: Tue, 27 Oct 2026 00:25:27 +0300 Subject: [PATCH] feat(console): one VNC console page, driven from the panel's bar The VM page offered a lite and a full VNC console. Lite lacked a clipboard, display settings and a way to reconnect; full put noVNC's own side bar under the panel's bar, with a Fullscreen item that called a function noVNC does not have, status messages hidden behind the bar, saved noVNC settings that overrode the Access tab's, connection settings that cannot apply, and a Connect button that reused the page's two-minute token and failed. console-vnc.html and static/js/console_vnc.js replace both, on noVNC's RFB with Bootstrap controls, and keep what the full view did: - Status in the bar (Connecting, Connected, Disconnected, Connection lost, Could not connect, Authentication failed with the server's reason on hover); Disconnect, then Reconnect, which loads the page again for a new token. - The VNC password in a dialog (with a username when the server asks for one), not a browser prompt; without passwordless console it is not in the page. - Send key(s) gains Hold Ctrl / Alt / Windows (noVNC's extra keys). - Clipboard: what the VM copies, sending text to the VM's clipboard, and typing it key by key (new: works in firmware, installers and login prompts; Shift for the characters a US keyboard shifts, 25 ms a key as firmware drops faster keys, verified in a UEFI shell). - View: scaling (none, fit, resize the VM's screen), clip, drag a clipped screen, view only, dot cursor, quality, compression; it starts as Settings > Console says (a touch screen starts fitted). - A Keyboard item on touch devices (noVNC's on-screen keyboard input, key events and Android's text-only input), Fullscreen with keyboard lock, the bell. Dropped with the full view: noVNC's websocket host/port/path, repeater ID and log level (the panel fixes them), automatic reconnect (the token is single use in time), XVP power buttons (QEMU has no XVP) and shared-mode off. The bar is compact (about 45 px in the 850x600 window). The Access tab has one Console button instead of the Lite/Full split and four switches, and it and the VM list open one window per VM (opening it again reloads it). --- console/templates/console-base.html | 10 +- console/templates/console-menu.html | 7 +- console/templates/console-vnc-full.html | 382 ------------------ console/templates/console-vnc-lite.html | 207 ---------- console/templates/console-vnc.html | 121 ++++++ console/tests/test_views.py | 231 ++++------- console/views.py | 36 +- instances/templates/instance_actions.html | 4 +- instances/templates/instances/access_tab.html | 60 +-- instances/tests/test_page_controls.py | 2 +- static/js/console_keys.js | 81 +++- static/js/console_vnc.js | 222 ++++++++++ 12 files changed, 520 insertions(+), 843 deletions(-) delete mode 100755 console/templates/console-vnc-full.html delete mode 100755 console/templates/console-vnc-lite.html create mode 100644 console/templates/console-vnc.html create mode 100644 static/js/console_vnc.js diff --git a/console/templates/console-base.html b/console/templates/console-base.html index 6f76096c..609f413c 100644 --- a/console/templates/console-base.html +++ b/console/templates/console-base.html @@ -21,7 +21,7 @@ padding: 0; width: 100%; max-width: none; - height: 100vh; + height: 100dvh; display: flex; flex-direction: column; background-color: #494949; @@ -42,9 +42,10 @@
-
diff --git a/console/templates/console-vnc-full.html b/console/templates/console-vnc-full.html deleted file mode 100755 index 785a716a..00000000 --- a/console/templates/console-vnc-full.html +++ /dev/null @@ -1,382 +0,0 @@ -{% extends "console-base.html" %} -{% load i18n %} -{% load static %} - -{% block head %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -{% endblock %} - -{% block navbarmenu %}{% include "console-menu.html" %}{% endblock %} - -{% block content %} -
-
-
{% trans 'noVNC encountered an error' %}:
-
-
-
-
- - -
- -
-
-
-
- -
- -

no
VNC

- - - - - -
- -
- - - -
-
- - - - - - -
-
- - - -
-
-
- Power -
- - - -
-
- - - -
-
-
- Clipboard -
- -
- -
-
- - - - - - -
-
-
    -
  • - Settings -
  • -
  • - -
  • -
  • - -
  • -
  • -
    -
  • -
  • - -
  • -
  • - - -
  • -
  • -
    -
  • -
  • -
    Advanced
    -
      -
    • - - -
    • -
    • - - -
    • -

    • -
    • - - -
    • -
    • -
      WebSocket
      -
        -
      • - -
      • -
      • - - -
      • -
      • - - -
      • -
      • - - - -
      • -
      -
    • -

    • -
    • - -
    • -
    • - - -
    • -

    • -
    • - -
    • -

    • - -
    • - -
    • -
    -
  • -

  • -
  • - Version: - -
  • -
-
-
- - - - -
-
- -
- -
-
-
-
- - -
- - -
-
- -
- -
-
-
- - -
-
-
- {% trans 'Server identity' %} -
-
- {% trans 'The server has provided the following identifying information:' %} -
-
- {% trans 'Fingerprint:' %} - -
-
- {% trans 'Please verify that the information is correct and press "Approve". Otherwise press "Reject".' %} -
-
- - -
-
-
- - -
-
-
- {% trans 'Credentials' %} -
-
- - -
-
- - {% if perms.instances.passwordless_console %} - - {% else %} - - {% endif %} -
-
- -
-
-
- - -
-
-
- -
-
-
- - -
- - -
- - -{% endblock %} \ No newline at end of file diff --git a/console/templates/console-vnc-lite.html b/console/templates/console-vnc-lite.html deleted file mode 100755 index 63d10f70..00000000 --- a/console/templates/console-vnc-lite.html +++ /dev/null @@ -1,207 +0,0 @@ -{% extends "console-base.html" %} -{% load i18n %} -{% load static %} - -{% block head %} - - - - - - -{% endblock %} - -{% block navbarmenu %} -{% include "console-menu.html" %} - -{% endblock %} - -{% block content %} -
-
-
{% trans 'Loading' %}
-
- - - - - - -
-
-
- -
-{% endblock %} \ No newline at end of file diff --git a/console/templates/console-vnc.html b/console/templates/console-vnc.html new file mode 100644 index 00000000..e408b757 --- /dev/null +++ b/console/templates/console-vnc.html @@ -0,0 +1,121 @@ +{% extends "console-base.html" %} +{% load i18n %} +{% load static %} + +{% block head %} + +{{ vnc|json_script:"vnc-config" }} + +{% endblock %} + +{% block navbarstatus %} +{% trans "Connecting" %} +{% endblock %} + +{% block navbarmenu %} +{% include "console-menu.html" %} + + + +{% endblock %} + +{% block navbarend %} + +{% endblock %} + +{% block content %} +
+ + + + + + + +{% endblock %} diff --git a/console/tests/test_views.py b/console/tests/test_views.py index 9be41e6b..be51a733 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -1,17 +1,19 @@ # pylint: disable=no-member import importlib.machinery +import json import importlib.util from pathlib import Path from unittest.mock import MagicMock, patch from accounts.models import UserInstance +from appsettings.models import AppSettings from computes.models import Compute from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.core.signing import TimestampSigner +from django.conf import settings from django.test import TestCase, override_settings from django.urls import reverse -from django.utils.html import escapejs from instances.models import Instance from libvirt import libvirtError from lxml import html @@ -28,6 +30,11 @@ novncd_mod = importlib.util.module_from_spec(_spec) _loader.exec_module(novncd_mod) +def vnc_config(response): + """What the VNC page's script starts from.""" + return json.loads(html.fromstring(response.content).get_element_by_id("vnc-config").text) + + class ConsoleViewsTestCase(TestCase): @classmethod def setUpTestData(cls): @@ -72,52 +79,25 @@ class ConsoleViewsTestCase(TestCase): cls.url = reverse("console", args=[cls.compute.id, cls.instance.uuid]) @patch("console.views.wvmInstance") - def test_console_superuser_vnc_lite(self, mock_wvm): + def test_console_superuser_vnc(self, mock_wvm): mock_conn = MagicMock() mock_conn.get_console_type.return_value = "vnc" + mock_conn.get_console_passwd.return_value = None mock_conn.get_console_passwd.return_value = "vncsecret" mock_wvm.return_value = mock_conn self.client.force_login(self.admin_user) - url = self.url + "?view=lite" - response = self.client.get(url) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-lite.html") + self.assertTemplateUsed(response, "console-vnc.html") + config = vnc_config(response) signer = TimestampSigner(salt="console.novnc") - unsigned = signer.unsign(response.context["token"], max_age=120) + unsigned = signer.unsign(config["token"], max_age=120) self.assertEqual( unsigned, f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}" ) - self.assertEqual(response.context["console_passwd"], "vncsecret") - - @patch("console.views.wvmInstance") - def test_console_superuser_vnc_full(self, mock_wvm): - mock_conn = MagicMock() - mock_conn.get_console_type.return_value = "vnc" - mock_wvm.return_value = mock_conn - - self.client.force_login(self.admin_user) - url = self.url + "?view=full" - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-full.html") - # a VM with a QEMU websocket port goes through novncd too, on its VNC port - self.assertContains(response, "port: Number(window.location.port") - - @patch("console.views.wvmInstance") - def test_the_lite_console_switches_scaling_from_its_menu(self, mock_wvm): - mock_wvm.return_value.get_console_type.return_value = "vnc" - self.client.force_login(self.admin_user) - for query, pressed in (("", "false"), ("&scale=true", "true")): - with self.subTest(query=query): - page = html.fromstring(self.client.get(self.url + f"?view=lite{query}").content) - switch = page.xpath("//*[@id='scale_button']//a")[0] - self.assertEqual(switch.get("aria-pressed"), pressed) - self.assertEqual("active" in switch.get("class").split(), pressed == "true") - page = html.fromstring(self.client.get(self.url + "?view=full").content) - self.assertEqual(page.xpath("//*[@id='scale_button']"), []) # noVNC's own settings scale the full view + self.assertEqual(config["password"], "vncsecret") @patch("console.views.wvmInstance") def test_console_unsupported_graphics_type(self, mock_wvm): @@ -127,8 +107,7 @@ class ConsoleViewsTestCase(TestCase): mock_wvm.return_value = mock_conn self.client.force_login(self.admin_user) - url = self.url + "?view=full" - response = self.client.get(url) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "console-error.html") @@ -170,6 +149,7 @@ class ConsoleViewsTestCase(TestCase): def test_console_user_assigned_instance_access(self, mock_wvm): mock_conn = MagicMock() mock_conn.get_console_type.return_value = "vnc" + mock_conn.get_console_passwd.return_value = None mock_wvm.return_value = mock_conn self.client.force_login(self.alice) @@ -177,14 +157,14 @@ class ConsoleViewsTestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-lite.html") + self.assertTemplateUsed(response, "console-vnc.html") @patch("console.views.wvmInstance") def test_the_console_password_reaches_the_page_only_with_passwordless_console(self, mock_wvm): # without the permission noVNC asks for the password; the page must not hold it - # (letters and digits only: escapejs keeps them as they are in the lite view) mock_conn = MagicMock() mock_conn.get_console_type.return_value = "vnc" + mock_conn.get_console_passwd.return_value = None mock_conn.get_console_passwd.return_value = "vncpw7f3a" mock_wvm.return_value = mock_conn permission = Permission.objects.get(codename="passwordless_console") @@ -192,12 +172,11 @@ class ConsoleViewsTestCase(TestCase): for user, holds in ((self.alice, False), (self.alice, True), (self.admin_user, True)): if holds and not user.is_superuser: user.user_permissions.add(permission) - for view in ("lite", "full"): - with self.subTest(user=user.username, permission=holds, view=view): - self.client.force_login(get_user_model().objects.get(pk=user.pk)) # fresh permission cache - response = self.client.get(self.url + f"?view={view}") - self.assertEqual(response.status_code, 200) - self.assertEqual("vncpw7f3a" in response.content.decode(), holds) + with self.subTest(user=user.username, permission=holds): + self.client.force_login(get_user_model().objects.get(pk=user.pk)) # fresh permission cache + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual("vncpw7f3a" in response.content.decode(), holds) def test_console_unauthorized_user_forbidden(self): self.client.force_login(self.bob) @@ -232,42 +211,73 @@ class ConsoleViewsTestCase(TestCase): self.assertEqual(page.findtext(".//title"), "vm-alpha - Console") @patch("console.views.wvmInstance") - def test_console_lite_uses_the_signed_token(self, mock_wvm): + def test_the_vnc_page_carries_the_signed_token(self, mock_wvm): mock_conn = MagicMock() mock_conn.get_console_type.return_value = "vnc" + mock_conn.get_console_passwd.return_value = None mock_wvm.return_value = mock_conn self.client.force_login(self.admin_user) response = self.client.get(self.url) - signed = response.context["token"] + signed = vnc_config(response)["token"] self.assertEqual(TimestampSigner(salt="console.novnc").unsign(signed), f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}") - self.assertContains(response, f"const token = '{escapejs(signed)}'") @patch("console.views.wvmInstance") def test_a_shut_off_vm_says_so(self, mock_wvm): mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.get_console_passwd.return_value = None mock_wvm.return_value.instance.isActive.return_value = 0 self.client.force_login(self.alice) response = self.client.get(self.url) self.assertContains(response, "The VM is not running") @patch("console.views.wvmInstance") - def test_the_lite_console_connects_only_to_the_servers_proxy(self, mock_wvm): - # a link naming another host would send it the signed console token + def test_the_vnc_page_connects_only_to_the_servers_proxy(self, mock_wvm): + # a link naming another host would send it the signed console token; + # nginx proxies /novncd/ on the panel's own scheme, host and port mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.get_console_passwd.return_value = None self.client.force_login(self.alice) - response = self.client.get(self.url + "?host=attacker.example&port=443&path=x") + response = self.client.get(self.url + "?host=attacker.example&port=443&path=x&password=x") self.assertNotContains(response, "attacker.example") - # nor a password from it: a link would leave it in history and logs - self.assertNotContains(response, "readQueryVariable") - self.assertContains(response, "const host = window.location.hostname;") + self.assertEqual(set(vnc_config(response)), {"token", "password", "resize", "clip", "viewOnly"}) + script = (Path(settings.BASE_DIR) / "static/js/console_vnc.js").read_text() + self.assertIn("scheme + window.location.host + '/novncd/?token='", script) @patch("console.views.wvmInstance") + def test_the_vnc_page_starts_as_the_console_settings_say(self, mock_wvm): + mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.get_console_passwd.return_value = None + self.client.force_login(self.alice) + names = ("CONSOLE_SCALE", "CONSOLE_RESIZE_SESSION", "CONSOLE_CLIP_VIEWPORT", "CONSOLE_VIEW_ONLY") + for values, resize, clip, view_only in ( + (("False", "False", "False", "False"), "off", False, False), + (("True", "False", "True", "True"), "scale", True, True), + (("True", "True", "False", "False"), "remote", False, False), + ): + with self.subTest(values=values): + for name, value in zip(names, values): + AppSettings.objects.filter(key=name).update(value=value) + config = vnc_config(self.client.get(self.url)) + self.assertEqual((config["resize"], config["clip"], config["viewOnly"]), (resize, clip, view_only)) + + @patch("console.views.wvmInstance") + def test_the_vnc_password_stays_data_in_the_page(self, mock_wvm): + # json_script: no quote or tag in it ends the script element + mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.get_console_passwd.return_value = None + mock_wvm.return_value.get_console_passwd.return_value = "O'B")[0] - for s in content_full.split(" 'off' - url = self.url + "?view=full" - resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) - content = resp.content.decode("utf-8") - self.assertIn("resize: 'off',", content) - self.assertNotIn("'off'", content) - self.assertNotIn("'off'", content) - - # scale=True, resize_session=False -> 'scale' - url_scale = self.url + "?view=full&scale=true" - resp_scale = self.client.get(url_scale) - self.assertEqual(resp_scale.status_code, 200) - content_scale = resp_scale.content.decode("utf-8") - self.assertIn("resize: 'scale',", content_scale) - - # resize_session=True -> 'remote' - url_remote = ( - self.url + "?view=full&resize_session=true" - ) - resp_remote = self.client.get(url_remote) - self.assertEqual(resp_remote.status_code, 200) - content_remote = resp_remote.content.decode("utf-8") - self.assertIn("resize: 'remote',", content_remote) - @patch("console.views.wvmInstance") def test_console_libvirt_error_fallback(self, mock_wvm): mock_wvm.side_effect = libvirtError("Connection to libvirtd failed") @@ -441,11 +351,11 @@ class ConsoleViewsTestCase(TestCase): def test_console_send_keys_menu_elements(self, mock_wvm): mock_conn = MagicMock() mock_conn.get_console_type.return_value = "vnc" + mock_conn.get_console_passwd.return_value = None mock_wvm.return_value = mock_conn self.client.force_login(self.admin_user) - url = self.url + "?view=lite" - response = self.client.get(url) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) content = response.content.decode("utf-8") @@ -460,6 +370,9 @@ class ConsoleViewsTestCase(TestCase): "ctrlz", "ctrld", "ctrlbackslash", + "holdctrl", + "holdalt", + "holdsuper", ] + [f"ctrlaltf{i}" for i in range(1, 13)] for key_id in expected_ids: diff --git a/console/views.py b/console/views.py index a3f7a223..4dc8c017 100644 --- a/console/views.py +++ b/console/views.py @@ -13,22 +13,9 @@ from vrtManager.instance import wvmInstance from vrtManager.util import is_loopback -def to_bool(val, default=False): - if val is None: - return default - return str(val).strip().lower() in ("true", "1", "yes") - - def console(request, compute_id, uuid): - """The console page of the VM with this UUID on this compute. The page's - query gives the view (lite or full) and its display options. A console + """The console page of the VM with this UUID on this compute. A console that cannot open gets a page that says why, and no console client.""" - view_type = "full" if request.GET.get("view", "").strip().lower() == "full" else "lite" - view_only = to_bool(request.GET.get("view_only"), default=to_bool(app_settings.CONSOLE_VIEW_ONLY)) - scale = to_bool(request.GET.get("scale"), default=to_bool(app_settings.CONSOLE_SCALE)) - resize_session = to_bool(request.GET.get("resize_session"), default=to_bool(app_settings.CONSOLE_RESIZE_SESSION)) - clip_viewport = to_bool(request.GET.get("clip_viewport"), default=to_bool(app_settings.CONSOLE_CLIP_VIEWPORT)) - instance = Instance.objects.filter(compute_id=compute_id, uuid=uuid).first() # Same answer for "no such VM" and "not allowed", so the response # does not reveal whether a VM exists. @@ -84,5 +71,22 @@ def console(request, compute_id, uuid): # the console daemons' credential for this page token = tokens.SIGNER.sign(f"{compute_id}:{uuid}:{request.user.id}") - console_page = "console-xterm.html" if console_type == "pty" else "console-vnc-" + view_type + ".html" - return render(request, console_page, locals()) + if console_type == "pty": + return render(request, "console-xterm.html", {"instance": instance, "token": token}) + + # how the VNC page starts (Settings > Console); its View menu changes them + if app_settings.CONSOLE_RESIZE_SESSION == "True": + resize = "remote" + elif app_settings.CONSOLE_SCALE == "True": + resize = "scale" + else: + resize = "off" + vnc = { + "token": token, + # without the permission the VNC server asks the user for it + "password": console_passwd if request.user.has_perm("instances.passwordless_console") else None, + "resize": resize, + "clip": app_settings.CONSOLE_CLIP_VIEWPORT == "True", + "viewOnly": app_settings.CONSOLE_VIEW_ONLY == "True", + } + return render(request, "console-vnc.html", {"instance": instance, "vnc": vnc}) diff --git a/instances/templates/instance_actions.html b/instances/templates/instance_actions.html index 36ee94c3..041cf5b6 100644 --- a/instances/templates/instance_actions.html +++ b/instances/templates/instance_actions.html @@ -59,8 +59,8 @@ {% endif %} {% if "console" in actions %} - {% else %} diff --git a/instances/templates/instances/access_tab.html b/instances/templates/instances/access_tab.html index 2c7f5e0e..9d3cce57 100644 --- a/instances/templates/instances/access_tab.html +++ b/instances/templates/instances/access_tab.html @@ -37,49 +37,11 @@ {% if not can_open_console %}

{% trans "Only the owners of this instance and administrators can open its console." %}

{% else %} - {% if instance.console_type == 'vnc' %} -
- - -
-
- - -
-
- - -
-
- - -
- {% endif %} {% if instance.status == 1 %} - - + {# one window per VM: opening it again reloads it #} + {% else %} {% endif %} @@ -153,17 +115,3 @@ {% endif %} -{% block script %} -{% if can_open_console %} - -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/instances/tests/test_page_controls.py b/instances/tests/test_page_controls.py index 7cc0e1b3..739670db 100644 --- a/instances/tests/test_page_controls.py +++ b/instances/tests/test_page_controls.py @@ -84,7 +84,7 @@ class PageControls: return bool(doc.xpath("//form[@action=$a]", a=reverse(f"instances:{name}", args=[pk]))) found = { - "console": bool(doc.xpath("//button[@id='consoleBtnGroup']")), + "console": bool(doc.xpath("//button[@id='console_button']")), "vv file": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:getvvfile", args=[pk]))), "vdi": bool(doc.xpath("//*[@id='vdiconsole']")), "power": form("poweroff") or form("poweron"), diff --git a/static/js/console_keys.js b/static/js/console_keys.js index 4c26eea7..d931128a 100644 --- a/static/js/console_keys.js +++ b/static/js/console_keys.js @@ -1,13 +1,32 @@ -// The keys menu of the VNC console pages (lite and full): key combinations -// the browser or the operating system would otherwise catch. +// The keys menu of the VNC console page: key combinations the browser or the +// operating system would otherwise catch, and modifiers a device lacks. import KeyTable from './novnc/core/input/keysym.js'; const CTRL = { keysym: KeyTable.XK_Control_L, code: 'ControlLeft' }; const ALT = { keysym: KeyTable.XK_Alt_L, code: 'AltLeft' }; +const SUPER = { keysym: KeyTable.XK_Super_L, code: 'MetaLeft' }; +const HOLDS = [['holdctrl', CTRL], ['holdalt', ALT], ['holdsuper', SUPER]]; + +function hold(rfb, id, key, down) { + const item = document.getElementById(id); + rfb.sendKey(key.keysym, key.code, down); + item.setAttribute('aria-pressed', down); + item.classList.toggle('active', down); +} + +// the held modifiers up: before a combination from the menu (which presses +// and releases its own), and before the page stops sending keys +export function releaseHeld(rfb) { + for (const [id, key] of HOLDS) { + if (document.getElementById(id).getAttribute('aria-pressed') === 'true') { + hold(rfb, id, key, false); + } + } +} // press the keys in order, release them in reverse function sendKeys(rfb, keys) { - if (!rfb || (rfb._rfbConnectionState && rfb._rfbConnectionState !== 'connected')) { return; } + releaseHeld(rfb); keys.forEach((key) => rfb.sendKey(key.keysym, key.code, true)); setTimeout(() => [...keys].reverse().forEach((key) => rfb.sendKey(key.keysym, key.code, false)), 20); } @@ -22,20 +41,15 @@ export function bindKeyAction(id, handler) { } } -// getRfb: the page's RFB object, which the full page creates on connect -export function bindSendKeys(getRfb) { - const send = (...keys) => () => sendKeys(getRfb(), keys); - const ctrlAltDel = () => { - const rfb = getRfb(); - if (rfb && typeof rfb.sendCtrlAltDel === 'function') { - rfb.sendCtrlAltDel(); - } - }; - bindKeyAction('ctrlaltdel', ctrlAltDel); - bindKeyAction('sendCtrlAltDelButton', ctrlAltDel); +export function bindSendKeys(rfb) { + const send = (...keys) => () => sendKeys(rfb, keys); + bindKeyAction('ctrlaltdel', () => { + releaseHeld(rfb); + rfb.sendCtrlAltDel(); + }); bindKeyAction('ctrlaltbackspace', send(CTRL, ALT, { keysym: KeyTable.XK_BackSpace, code: 'Backspace' })); bindKeyAction('alttab', send(ALT, { keysym: KeyTable.XK_Tab, code: 'Tab' })); - bindKeyAction('superkey', send({ keysym: KeyTable.XK_Super_L, code: 'MetaLeft' })); + bindKeyAction('superkey', send(SUPER)); bindKeyAction('sendesc', send({ keysym: KeyTable.XK_Escape, code: 'Escape' })); bindKeyAction('sendtab', send({ keysym: KeyTable.XK_Tab, code: 'Tab' })); bindKeyAction('ctrlc', send(CTRL, { keysym: KeyTable.XK_c, code: 'KeyC' })); @@ -45,4 +59,41 @@ export function bindSendKeys(getRfb) { for (let i = 1; i <= 12; i++) { bindKeyAction('ctrlaltf' + i, send(CTRL, ALT, { keysym: KeyTable['XK_F' + i], code: 'F' + i })); } + // a held modifier stays down until it is chosen again: with the next key + // typed it makes a combination the menu does not list + for (const [id, key] of HOLDS) { + bindKeyAction(id, () => { + // a view-only page sends no keys: nothing to hold + if (!rfb.viewOnly) { + hold(rfb, id, key, document.getElementById(id).getAttribute('aria-pressed') !== 'true'); + } + }); + } +} + +// the Fullscreen item; in full screen the page also takes the keys the +// browser would keep (Alt, Tab, Escape) where the browser lets it +export function bindFullscreen() { + const item = document.getElementById('fullscreen_button'); + if (!document.fullscreenEnabled) { + item.hidden = true; + return; + } + bindKeyAction('fullscreen_button', () => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + document.documentElement.requestFullscreen().catch(() => {}); + } + }); + document.addEventListener('fullscreenchange', () => { + if (!navigator.keyboard) { + return; + } + if (document.fullscreenElement) { + navigator.keyboard.lock(['AltLeft', 'AltRight', 'Tab', 'Escape']).catch(() => {}); + } else { + navigator.keyboard.unlock(); + } + }); } diff --git a/static/js/console_vnc.js b/static/js/console_vnc.js new file mode 100644 index 00000000..5f923f0e --- /dev/null +++ b/static/js/console_vnc.js @@ -0,0 +1,222 @@ +// The VNC console page: noVNC's RFB in the page's screen, driven from the +// panel's bar: status, keys, clipboard, view, touch keyboard, reconnect. +import RFB from './novnc/core/rfb.js'; +import Keyboard from './novnc/core/input/keyboard.js'; +import KeyTable from './novnc/core/input/keysym.js'; +import keysyms from './novnc/core/input/keysymdef.js'; +import { isTouchDevice } from './novnc/core/util/browser.js'; +import { bindFullscreen, bindKeyAction, bindSendKeys, releaseHeld } from './console_keys.js'; + +const config = JSON.parse(document.getElementById('vnc-config').textContent); +const status = document.getElementById('status'); +const connectionButton = document.getElementById('connection_button'); + +// novncd behind the panel's own address (nginx's /novncd/), never a host from +// the link; the page's token is its credential +const scheme = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; +const url = scheme + window.location.host + '/novncd/?token=' + encodeURIComponent(config.token); +const rfb = new RFB(document.getElementById('screen'), url, + { credentials: config.password ? { password: config.password } : {} }); + +// ---- status and reconnect + +let connected = false; +let closed = false; +let refusal = null; + +function showStatus(text, color) { + status.textContent = text; + status.className = 'badge text-bg-' + color; +} + +rfb.addEventListener('connect', () => { + connected = true; + showStatus(status.dataset.connected, 'success'); + rfb.focus(); +}); +// the server's own words (untranslated) on hover +rfb.addEventListener('securityfailure', (e) => { + refusal = status.dataset.refused; + status.title = (e.detail.reason || '').replace(/\0/g, ''); +}); +rfb.addEventListener('disconnect', (e) => { + closed = true; + if (refusal) { + showStatus(refusal, 'danger'); + } else if (!connected) { + showStatus(status.dataset.failed, 'danger'); + } else { + showStatus(e.detail.clean ? status.dataset.disconnected : status.dataset.lost, 'danger'); + } + connectionButton.textContent = connectionButton.dataset.reconnect; + credentialsDialog.hide(); +}); +// the token is good for one connection soon after the page loads: a new +// connection takes a new page +connectionButton.addEventListener('click', () => { + if (closed) { + window.location.reload(); + } else { + rfb.disconnect(); + } +}); + +// ---- the VNC password, when the server asks for it + +const credentialsDialog = new bootstrap.Modal('#credentials_dialog'); +const username = document.getElementById('credentials_username'); +const password = document.getElementById('credentials_password'); + +rfb.addEventListener('credentialsrequired', (e) => { + document.getElementById('credentials_username_field').hidden = !e.detail.types.includes('username'); + credentialsDialog.show(); +}); +document.getElementById('credentials_dialog').addEventListener('shown.bs.modal', () => { + // closed while it was opening: a dialog still opening ignores hide() + if (closed) { + credentialsDialog.hide(); + return; + } + (username.offsetParent ? username : password).focus(); +}); +document.getElementById('credentials_form').addEventListener('submit', (e) => { + e.preventDefault(); + const credentials = { password: password.value }; + if (username.offsetParent) { + credentials.username = username.value; + } + password.value = ''; + credentialsDialog.hide(); + rfb.sendCredentials(credentials); +}); +bindKeyAction('credentials_cancel', () => { + credentialsDialog.hide(); + rfb.disconnect(); +}); + +// ---- keys, clipboard and typed text + +bindSendKeys(rfb); +bindFullscreen(); + +// the characters a US keyboard types with Shift: the VM gets keys, and its +// key for "A" is the key for "a" +const SHIFTED = /[A-Z~!@#$%^&*()_+{}|:"<>?]/; + +const typedKeys = { + '\n': [KeyTable.XK_Return, 'Enter'], + '\b': [KeyTable.XK_BackSpace, 'Backspace'], + '\t': [KeyTable.XK_Tab, 'Tab'], +}; + +async function typeCharacter(character) { + // a typist's pace: firmware drops keys that come faster + await new Promise((resolve) => setTimeout(resolve, 25)); + const [keysym, code] = typedKeys[character] || [keysyms.lookup(character.codePointAt(0)), null]; + if (SHIFTED.test(character)) { + rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', true); + rfb.sendKey(keysym, code); + rfb.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', false); + } else { + rfb.sendKey(keysym, code); + } +} + +// key by key, after what is still being typed: works where no clipboard does +// (firmware, installers, login prompts); "\b" is a Backspace +let typing = Promise.resolve(); +function typeText(text) { + for (const character of text.replace(/\r\n?/g, '\n')) { + typing = typing.then(() => typeCharacter(character)); + } +} + +const clipboardText = document.getElementById('clipboard_text'); +const clipboardDialog = bootstrap.Modal.getOrCreateInstance('#clipboard_dialog'); +rfb.addEventListener('clipboard', (e) => { + clipboardText.value = e.detail.text; +}); +bindKeyAction('clipboard_send', () => { + rfb.clipboardPasteFrom(clipboardText.value); + clipboardDialog.hide(); +}); +bindKeyAction('clipboard_type', () => { + clipboardDialog.hide(); + typeText(clipboardText.value); +}); + +const bell = document.getElementById('bell'); +rfb.addEventListener('bell', () => bell.play().catch(() => {})); + +// back to the VM's screen after a menu or a dialog +document.addEventListener('hidden.bs.dropdown', () => rfb.focus()); +document.addEventListener('hidden.bs.modal', () => rfb.focus()); + +// ---- the View menu + +const resize = document.getElementById('view_resize'); +const clip = document.getElementById('view_clip'); +const drag = document.getElementById('view_drag'); +const viewOnly = document.getElementById('view_only'); +const dot = document.getElementById('view_dot'); +const quality = document.getElementById('view_quality'); +const compression = document.getElementById('view_compression'); + +function applyView() { + rfb.scaleViewport = resize.value === 'scale'; + rfb.resizeSession = resize.value === 'remote'; + // a screen fitted to the window has nothing to clip, an unclipped one nothing to drag + clip.disabled = rfb.scaleViewport; + rfb.clipViewport = clip.checked && !rfb.scaleViewport; + drag.disabled = !rfb.clipViewport; + rfb.dragViewport = drag.checked && rfb.clipViewport; + if (viewOnly.checked) { + releaseHeld(rfb); // while it still sends keys + } + rfb.viewOnly = viewOnly.checked; + rfb.showDotCursor = dot.checked; + rfb.qualityLevel = Number(quality.value); + rfb.compressionLevel = Number(compression.value); +} + +// a touch screen cannot scroll a screen larger than the window: it starts fitted +resize.value = isTouchDevice && config.resize === 'off' ? 'scale' : config.resize; +clip.checked = config.clip; +viewOnly.checked = config.viewOnly; +applyView(); +document.getElementById('view_menu').addEventListener('change', applyView); + +// ---- the on-screen keyboard of a touch device + +if (isTouchDevice) { + const keyboardInput = document.getElementById('keyboard_input'); + document.getElementById('keyboard_button').hidden = false; + bindKeyAction('keyboard_button', () => keyboardInput.focus()); + keyboardInput.addEventListener('focus', () => { rfb.focusOnClick = false; }); + keyboardInput.addEventListener('blur', () => { rfb.focusOnClick = true; }); + + // the keys it reports as key events + const keyboard = new Keyboard(keyboardInput); + keyboard.onkeyevent = (keysym, code, down) => rfb.sendKey(keysym, code, down); + keyboard.grab(); + + // the text it reports only as input (Android): what changed since the + // last input, as Backspaces and characters; the field always holds text + // for a Backspace to delete + let lastText = ''; + const resetInput = () => { keyboardInput.value = lastText = '_'.repeat(100); }; + resetInput(); + keyboardInput.addEventListener('input', () => { + const text = keyboardInput.value; + let same = 0; + while (same < Math.min(text.length, lastText.length) && text[same] === lastText[same]) { + same++; + } + typeText('\b'.repeat(lastText.length - same) + text.slice(same)); + if (text.length === 0 || text.length > 200) { + resetInput(); + } else { + lastText = text; + } + }); +}