// 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; } }); }