1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/static/js/console_vnc.js
catborise bfa0fc9fe5 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.
2026-10-27 12:23:32 +03:00

263 lines
10 KiB
JavaScript

// 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';
import { layoutKeys } from './console_layouts.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 VM's keyboard layout, the viewer's last choice
const layout = document.getElementById('clipboard_layout');
try {
layout.value = localStorage.getItem('console_layout') || layout.value;
} catch {}
layout.selectedIndex = Math.max(layout.selectedIndex, 0); // a layout no longer offered
layout.addEventListener('change', () => {
try {
localStorage.setItem('console_layout', layout.value);
} catch {}
});
const typedKeys = {
'\n': [KeyTable.XK_Return, 'Enter'],
'\b': [KeyTable.XK_BackSpace, 'Backspace'],
'\t': [KeyTable.XK_Tab, 'Tab'],
' ': [KeyTable.XK_space, 'Space'],
};
const modifierKeysyms = { ShiftLeft: KeyTable.XK_Shift_L, AltRight: KeyTable.XK_ISO_Level3_Shift };
// [keysym, key code, modifier codes], or nothing when no key types it: the
// VM makes the character of the key (its scancode), the keysym only names it
function keysFor(layoutId, character) {
if (character in typedKeys) {
return [...typedKeys[character], []];
}
const keys = layoutKeys(layoutId, character);
return keys && [keysyms.lookup(character.codePointAt(0)), ...keys];
}
async function typeKeys([keysym, code, modifiers]) {
// a typist's pace: firmware drops keys that come faster
await new Promise((resolve) => setTimeout(resolve, 25));
modifiers.forEach((modifier) => rfb.sendKey(modifierKeysyms[modifier], modifier, true));
rfb.sendKey(keysym, code);
[...modifiers].reverse().forEach((modifier) => rfb.sendKey(modifierKeysyms[modifier], modifier, false));
}
// key by key, after what is still being typed: works where no clipboard does
// (firmware, installers, login prompts); "\b" is a Backspace. All or nothing,
// a password or a command with a character left out is a wrong one: returns
// the characters no key types on the layout.
let typing = Promise.resolve();
function typeText(text) {
const characters = [...text.replace(/\r\n?/g, '\n')];
const keys = characters.map((character) => keysFor(layout.value, character));
const missing = characters.filter((character, i) => !keys[i]);
if (missing.length === 0) {
for (const characterKeys of keys) {
typing = typing.then(() => typeKeys(characterKeys));
}
}
return missing;
}
// a character as the user can see it: an invisible one by its code point
function shown(character) {
const invisible = /[\s\p{C}]/u.test(character);
return invisible ? 'U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') : character;
}
const clipboardText = document.getElementById('clipboard_text');
const clipboardMissing = document.getElementById('clipboard_missing');
const clipboardDialog = bootstrap.Modal.getOrCreateInstance('#clipboard_dialog');
rfb.addEventListener('clipboard', (e) => {
clipboardText.value = e.detail.text;
});
document.getElementById('clipboard_dialog').addEventListener('show.bs.modal', () => {
clipboardMissing.hidden = true;
});
bindKeyAction('clipboard_send', () => {
rfb.clipboardPasteFrom(clipboardText.value);
clipboardDialog.hide();
});
bindKeyAction('clipboard_type', () => {
const missing = typeText(clipboardText.value);
if (missing.length === 0) {
clipboardDialog.hide();
} else {
clipboardMissing.textContent = clipboardMissing.dataset.text + ' ' + [...new Set(missing)].map(shown).join(' ');
clipboardMissing.hidden = false;
}
});
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++;
}
// a character the layout has no key for: the field stays as the VM has it
if (typeText('\b'.repeat(lastText.length - same) + text.slice(same)).length > 0) {
keyboardInput.value = lastText;
} else if (text.length === 0 || text.length > 200) {
resetInput();
} else {
lastText = text;
}
});
}