1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(console): typed text follows the VM's keyboard layout

"Type it" sent each character's keysym and let QEMU pick the key from
its own US map, with Shift added for the US shifted characters. A VM
on another layout got other characters: on Turkish Q, '"' typed 'İ',
'@' typed "'", and ğ, ü, ş, ı, ö, ç typed nothing.

The Clipboard dialog now asks for the VM's keyboard layout and
remembers it per browser. Each character is typed as the key that makes
it on that layout (its scancode), with Shift, AltGr or both (AltGr
first: pressed after Shift, the right Alt is Alt on the Linux console),
so the VM's own layout makes the character. The layout is fixed when
typing starts. Text with a character no key types on the layout is not
typed at all, and the dialog lists those characters: a password or a
command with a character left out would be a wrong one.

A layout is one file in console/layouts/: its name and, for each key,
the characters it types plainly, with Shift, with AltGr and with
Shift+AltGr. The view offers every file, US first. dev/console_layout.py
writes a file from the system's XKB data as libxkbcommon computes it
(through ctypes, no new dependency), mapping keys to DOM codes with
noVNC's table from QEMU's keycodemapdb; it lists the dead keys it leaves
out, and drops the keys the keyboard model lacks (the key left of Z on
US pc104 keyboards). English (US) and Turkish Q come with it; the
README says how to add one.
This commit is contained in:
catborise 2026-10-27 08:37:04 +03:00
parent 2b54198965
commit bfa0fc9fe5
10 changed files with 431 additions and 21 deletions

54
console/layouts/tr.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "Turkish Q",
"source": {"xkb_layout": "tr", "xkb_variant": "", "xkb_model": "pc105"},
"keys": {
"Digit1": ["1", "!", ">", "¡"],
"Digit2": ["2", "'", "£", "²"],
"Digit3": ["3", "^", "#", "³"],
"Digit4": ["4", "+", "$", "¼"],
"Digit5": ["5", "%", "½", "⅜"],
"Digit6": ["6", "&", "¾", null],
"Digit7": ["7", "/", "{", null],
"Digit8": ["8", "(", "[", null],
"Digit9": ["9", ")", "]", "±"],
"Digit0": ["0", "=", "}", "°"],
"Minus": ["*", "?", "\\", "¿"],
"Equal": ["-", "_", "|", null],
"KeyQ": ["q", "Q", "@", "Ω"],
"KeyW": ["w", "W", null, null],
"KeyE": ["e", "E", "€", null],
"KeyR": ["r", "R", "¶", "®"],
"KeyT": ["t", "T", "₺", null],
"KeyY": ["y", "Y", "←", "¥"],
"KeyU": ["u", "U", "û", "Û"],
"KeyI": ["ı", "I", "î", "Î"],
"KeyO": ["o", "O", "ô", "Ô"],
"KeyP": ["p", "P", null, null],
"BracketLeft": ["ğ", "Ğ", null, null],
"BracketRight": ["ü", "Ü", "~", null],
"KeyA": ["a", "A", "â", "Â"],
"KeyS": ["s", "S", "ß", null],
"KeyD": ["d", "D", null, null],
"KeyF": ["f", "F", "ª", null],
"KeyG": ["g", "G", null, null],
"KeyH": ["h", "H", null, null],
"KeyJ": ["j", "J", null, null],
"KeyK": ["k", "K", null, null],
"KeyL": ["l", "L", null, null],
"Semicolon": ["ş", "Ş", "´", null],
"Quote": ["i", "İ", "'", null],
"Backquote": ["\"", "é", "<", "°"],
"Backslash": [",", ";", "`", null],
"KeyZ": ["z", "Z", "«", "<"],
"KeyX": ["x", "X", "»", ">"],
"KeyC": ["c", "C", "¢", "©"],
"KeyV": ["v", "V", "„", "‚"],
"KeyB": ["b", "B", "“", "‘"],
"KeyN": ["n", "N", "”", "’"],
"KeyM": ["m", "M", "µ", "º"],
"Comma": ["ö", "Ö", "×", null],
"Period": ["ç", "Ç", "·", "÷"],
"Slash": [".", ":", null, null],
"IntlBackslash": ["<", ">", "|", "¦"]
}
}

53
console/layouts/us.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "English (US)",
"source": {"xkb_layout": "us", "xkb_variant": "", "xkb_model": "pc104"},
"keys": {
"Digit1": ["1", "!", null, null],
"Digit2": ["2", "@", null, null],
"Digit3": ["3", "#", null, null],
"Digit4": ["4", "$", null, null],
"Digit5": ["5", "%", null, null],
"Digit6": ["6", "^", null, null],
"Digit7": ["7", "&", null, null],
"Digit8": ["8", "*", null, null],
"Digit9": ["9", "(", null, null],
"Digit0": ["0", ")", null, null],
"Minus": ["-", "_", null, null],
"Equal": ["=", "+", null, null],
"KeyQ": ["q", "Q", null, null],
"KeyW": ["w", "W", null, null],
"KeyE": ["e", "E", null, null],
"KeyR": ["r", "R", null, null],
"KeyT": ["t", "T", null, null],
"KeyY": ["y", "Y", null, null],
"KeyU": ["u", "U", null, null],
"KeyI": ["i", "I", null, null],
"KeyO": ["o", "O", null, null],
"KeyP": ["p", "P", null, null],
"BracketLeft": ["[", "{", null, null],
"BracketRight": ["]", "}", null, null],
"KeyA": ["a", "A", null, null],
"KeyS": ["s", "S", null, null],
"KeyD": ["d", "D", null, null],
"KeyF": ["f", "F", null, null],
"KeyG": ["g", "G", null, null],
"KeyH": ["h", "H", null, null],
"KeyJ": ["j", "J", null, null],
"KeyK": ["k", "K", null, null],
"KeyL": ["l", "L", null, null],
"Semicolon": [";", ":", null, null],
"Quote": ["'", "\"", null, null],
"Backquote": ["`", "~", null, null],
"Backslash": ["\\", "|", null, null],
"KeyZ": ["z", "Z", null, null],
"KeyX": ["x", "X", null, null],
"KeyC": ["c", "C", null, null],
"KeyV": ["v", "V", null, null],
"KeyB": ["b", "B", null, null],
"KeyN": ["n", "N", null, null],
"KeyM": ["m", "M", null, null],
"Comma": [",", "<", null, null],
"Period": [".", ">", null, null],
"Slash": ["/", "?", null, null]
}
}

View file

@ -7,6 +7,7 @@
#screen { flex: 1 1 auto; min-height: 0; overflow: hidden; }
</style>
{{ vnc|json_script:"vnc-config" }}
{{ layouts|json_script:"keyboard-layouts" }}
<script type="module" src="{% static "js/console_vnc.js" %}"></script>
{% endblock %}
@ -103,8 +104,16 @@
<label class="form-label" for="clipboard_text">{% trans "Text" %}</label>
<textarea class="form-control font-monospace" id="clipboard_text" rows="6"></textarea>
<div class="form-text">
{% trans "Type it: the VM gets it key by key, also in its firmware, an installer or a login prompt. The VM's clipboard takes it only with clipboard support in the VM; what the VM copies shows here." %}
{% trans "Type it: the VM gets it key by key, as the keys of its keyboard layout, also in its firmware, an installer or a login prompt. The VM's clipboard takes it only with clipboard support in the VM; what the VM copies shows here." %}
</div>
<label class="form-label mt-3" for="clipboard_layout">{% trans "The VM's keyboard layout" %}</label>
<select class="form-select" id="clipboard_layout">
{% for id, layout in layouts.items %}
<option value="{{ id }}">{{ layout.name }}</option>
{% endfor %}
</select>
<div class="alert alert-warning mt-3 mb-0" id="clipboard_missing" role="alert" hidden
data-text="{% trans "Nothing was typed: no key types these characters on this layout:" %}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="clipboard_send">{% trans "Send to the VM's clipboard" %}</button>

View file

@ -0,0 +1,47 @@
"""The keyboard layout files the VNC console types text with."""
import json
from pathlib import Path
from django.conf import settings
from django.test import SimpleTestCase
from console.views import LAYOUTS_DIR
SCANCODES = Path(settings.BASE_DIR) / "static/js/novnc/core/input/xtscancodes.js"
def unique_keys(pairs):
keys = [key for key, _ in pairs]
if len(keys) != len(set(keys)):
raise ValueError(f"a key twice: {keys}")
return dict(pairs)
class LayoutFilesTestCase(SimpleTestCase):
def test_each_layout_file_is_a_layout(self):
# the key codes noVNC sends as scancodes
codes = {line.split('"')[1] for line in SCANCODES.read_text().splitlines() if line.startswith(' "')}
paths = sorted(LAYOUTS_DIR.glob("*.json"))
self.assertIn("us", [path.stem for path in paths])
for path in paths:
with self.subTest(path.name):
layout = json.loads(path.read_text(), object_pairs_hook=unique_keys)
self.assertRegex(path.stem, r"^[a-z]+(-[a-z0-9_]+)?$")
self.assertEqual(set(layout), {"name", "source", "keys"})
self.assertTrue(layout["name"])
for code, characters in layout["keys"].items():
self.assertIn(code, codes)
self.assertEqual(len(characters), 4, code)
for character in characters:
# one printable character, or none
self.assertTrue(character is None or (len(character) == 1 and character.isprintable()),
f"{code}: {character!r}")
def test_us_and_turkish_q_type_every_printable_ascii_character(self):
ascii_characters = {chr(c) for c in range(0x21, 0x7F)}
for layout_id in ("us", "tr"):
with self.subTest(layout_id):
layout = json.loads((LAYOUTS_DIR / f"{layout_id}.json").read_text())
typed = {c for characters in layout["keys"].values() for c in characters if c}
self.assertEqual(ascii_characters - typed, set())

View file

@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
from accounts.models import UserInstance
from appsettings.models import AppSettings
from computes.models import Compute
from console.views import LAYOUTS_DIR
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.signing import TimestampSigner
@ -250,6 +251,19 @@ class ConsoleViewsTestCase(TestCase):
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_offers_each_keyboard_layout_file(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)
response = self.client.get(self.url)
layouts = json.loads(html.fromstring(response.content).get_element_by_id("keyboard-layouts").text)
files = sorted(path.stem for path in LAYOUTS_DIR.glob("*.json"))
self.assertEqual(sorted(layouts), files)
self.assertEqual(next(iter(layouts)), "us")
for layout_id, layout in layouts.items():
self.assertContains(response, f'<option value="{layout_id}">{layout["name"]}</option>', html=True)
@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"

View file

@ -1,5 +1,9 @@
# pylint: disable=no-name-in-module,no-member
import functools
import json
from pathlib import Path
from appsettings.settings import app_settings
from console import tokens
from django.conf import settings
@ -12,6 +16,22 @@ from vrtManager.connection import CONN_TCP, CONN_TLS
from vrtManager.instance import wvmInstance
from vrtManager.util import is_loopback
# one file a layout, written by dev/console_layout.py
LAYOUTS_DIR = Path(__file__).resolve().parent / "layouts"
@functools.cache
def keyboard_layouts():
"""The keyboard layouts the VNC console types text with, US first, then
by name: {id: {"name": ..., "keys": {DOM code: [plain, Shift, AltGr,
Shift+AltGr]}}}."""
layouts = {}
for path in LAYOUTS_DIR.glob("*.json"):
layout = json.loads(path.read_text())
layouts[path.stem] = {"name": layout["name"], "keys": layout["keys"]}
us = layouts.pop("us")
return {"us": us, **dict(sorted(layouts.items(), key=lambda item: item[1]["name"]))}
def console(request, compute_id, uuid):
"""The console page of the VM with this UUID on this compute. A console
@ -89,4 +109,4 @@ def console(request, compute_id, uuid):
"clip": app_settings.CONSOLE_CLIP_VIEWPORT == "True",
"viewOnly": app_settings.CONSOLE_VIEW_ONLY == "True",
}
return render(request, "console-vnc.html", {"instance": instance, "vnc": vnc})
return render(request, "console-vnc.html", {"instance": instance, "vnc": vnc, "layouts": keyboard_layouts()})