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 0532471759 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).
2026-10-27 08:05:31 +03:00

222 lines
8.2 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';
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;
}
});
}