mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 19:56:48 +00:00
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).
This commit is contained in:
parent
3a560e9f85
commit
0532471759
12 changed files with 520 additions and 843 deletions
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
222
static/js/console_vnc.js
Normal file
222
static/js/console_vnc.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue