feat: add configurable `ignore_keys` setting to filter key events
Introduce a new `ignore_keys` setting that allows users to prevent specific keyboard inputs from being forwarded to the remote VNC host while still allowing the browser/client to handle them locally. This addresses common UX issues where browser-level shortcuts (e.g. Escape to exit fullscreen or F11 for fullscreen toggle) are also sent to the VM, potentially interrupting workflows or triggering unintended actions. Key behavior: - Keys listed in `ignore_keys` are handled locally & not sent to the VM - Default value is to not ignore any key events - Supports comma-separated input with whitespace tolerance - Accepts aliases (e.g. "esc", "ctrl", "cmd") mapped to canonical codes - Matching is case-insensitive and normalized Implementation details: - Centralized supported keys via `supportedIgnoreKeys` - Added `normalizeIgnoreKey()` to map aliases to canonical codes - Introduced `wrapRfbSendKey()` to intercept and filter outgoing key events - Simplified `keyEvent()` to delegate to `rfb.sendKey` - Added validation for user input with visual feedback on invalid entries - Added dynamic tooltip and placeholder generation from supported key list UI changes: - Added `ignore_keys` input to settings panel - Added tooltip with supported key examples - Added inline validation styling for invalid entries Tests: - Added coverage for `shouldIgnoreKey()` including aliases, normalization, whitespace handling, and edge cases - Added tests for wrapped `sendKey` behavior to ensure filtering works - Updated `keyEvent()` tests to reflect pass-through behavior - Added validation and helper function tests This change improves usability when interacting with fullscreen mode and other browser-level shortcuts, while remaining backward compatible.
This commit is contained in:
parent
8e1ebdffba
commit
a06f2cf408
|
|
@ -612,7 +612,6 @@ html {
|
|||
list-style: none;
|
||||
padding: 0px;
|
||||
}
|
||||
#noVNC_settings button,
|
||||
#noVNC_settings select,
|
||||
#noVNC_settings textarea,
|
||||
#noVNC_settings input:not([type=checkbox]):not([type=radio]) {
|
||||
|
|
@ -620,6 +619,10 @@ html {
|
|||
/* Prevent inputs in settings from being too wide */
|
||||
max-width: calc(100% - 6px - var(--input-xpadding) * 2);
|
||||
}
|
||||
#noVNC_settings button:not(#noVNC_ignore_keys_help_button) {
|
||||
margin-left: 6px;
|
||||
max-width: calc(100% - 6px - var(--input-xpadding) * 2);
|
||||
}
|
||||
|
||||
#noVNC_setting_port {
|
||||
width: 80px;
|
||||
|
|
@ -643,6 +646,83 @@ html {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Help tooltips */
|
||||
.noVNC_setting_with_help {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.noVNC_setting_with_help label {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#noVNC_settings .noVNC_tooltip_list {
|
||||
margin: 0.4rem 0 0 0;
|
||||
padding-left: 0.8rem;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.noVNC_tooltip_list li {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
#noVNC_setting_ignore_keys::placeholder {
|
||||
color: var(--novnc-grey);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#noVNC_setting_ignore_keys.noVNC_invalid,
|
||||
#noVNC_setting_ignore_keys.noVNC_invalid:focus {
|
||||
border-color: var(--novnc-red);
|
||||
box-shadow: 0 0 2px rgba(var(--novnc-red-rgb), 0.5);
|
||||
}
|
||||
|
||||
#noVNC_ignore_keys_help_button {
|
||||
flex: 0 0 auto;
|
||||
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
min-width: 1.6rem;
|
||||
max-width: 1.6rem;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#noVNC_ignore_keys_tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
left: 1rem;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(20, 20, 20, 0.96);
|
||||
color: #fff;
|
||||
box-shadow: 0 0.4rem 1rem rgba(0, 0, 0, 0.35);
|
||||
font-size: 0.75rem;
|
||||
line-height: .9rem;
|
||||
}
|
||||
|
||||
#noVNC_ignore_keys_tooltip.noVNC_open {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
* Status dialog
|
||||
* ----------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
|
||||
--novnc-green: rgb(0, 128, 0);
|
||||
--novnc-yellow: rgb(255, 255, 0);
|
||||
--novnc-red-rgb: 229, 57, 53;
|
||||
--novnc-red: rgb(var(--novnc-red-rgb));
|
||||
}
|
||||
|
||||
/* ------ MISC PROPERTIES ------ */
|
||||
|
|
|
|||
229
app/ui.js
229
app/ui.js
|
|
@ -124,6 +124,7 @@ const UI = {
|
|||
UI.addConnectionControlHandlers();
|
||||
UI.addClipboardHandlers();
|
||||
UI.addSettingsHandlers();
|
||||
UI.initIgnoreKeysTooltip();
|
||||
document.getElementById("noVNC_status")
|
||||
.addEventListener('click', UI.hideStatus);
|
||||
|
||||
|
|
@ -191,6 +192,7 @@ const UI = {
|
|||
UI.initSetting('reconnect', false);
|
||||
UI.initSetting('reconnect_delay', 5000);
|
||||
UI.initSetting('keep_device_awake', false);
|
||||
UI.initSetting('ignore_keys', '');
|
||||
},
|
||||
// Adds a link to the label elements on the corresponding input elements
|
||||
setupSettingLabels() {
|
||||
|
|
@ -383,6 +385,13 @@ const UI = {
|
|||
UI.addSettingChangeHandler('logging', UI.updateLogging);
|
||||
UI.addSettingChangeHandler('reconnect');
|
||||
UI.addSettingChangeHandler('reconnect_delay');
|
||||
UI.addSettingChangeHandler('ignore_keys');
|
||||
const input = document.getElementById('noVNC_setting_ignore_keys');
|
||||
if (input && !input.dataset.validationBound) {
|
||||
input.addEventListener('input', UI.validateIgnoreKeysInput);
|
||||
input.addEventListener('blur', UI.validateIgnoreKeysInput);
|
||||
input.dataset.validationBound = 'true';
|
||||
}
|
||||
},
|
||||
|
||||
addFullscreenHandlers() {
|
||||
|
|
@ -802,13 +811,28 @@ const UI = {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
ctrl.value = value;
|
||||
ctrl.value = value ?? '';
|
||||
}
|
||||
},
|
||||
|
||||
// Save control setting to cookie
|
||||
saveSetting(name) {
|
||||
const ctrl = document.getElementById('noVNC_setting_' + name);
|
||||
// Update cookie and form control setting. If value is not set, then
|
||||
// updates from control to current cookie setting.
|
||||
saveSetting(nameOrEvent) {
|
||||
let ctrl = null;
|
||||
|
||||
if (typeof nameOrEvent === 'string') {
|
||||
ctrl = document.getElementById('noVNC_setting_' + nameOrEvent);
|
||||
} else if (nameOrEvent && nameOrEvent.target) {
|
||||
ctrl = nameOrEvent.target;
|
||||
} else if (this instanceof Element) {
|
||||
ctrl = this;
|
||||
}
|
||||
|
||||
if (!ctrl) {
|
||||
Log.Warn("saveSetting called without valid control");
|
||||
return null;
|
||||
}
|
||||
|
||||
let val;
|
||||
if (ctrl.type === 'checkbox') {
|
||||
val = ctrl.checked;
|
||||
|
|
@ -817,6 +841,17 @@ const UI = {
|
|||
} else {
|
||||
val = ctrl.value;
|
||||
}
|
||||
|
||||
let name = ctrl.id;
|
||||
if (name && name.startsWith('noVNC_setting_')) {
|
||||
name = name.slice('noVNC_setting_'.length);
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
Log.Warn("saveSetting could not determine setting name");
|
||||
return val;
|
||||
}
|
||||
|
||||
WebUtil.writeSetting(name, val);
|
||||
//Log.Debug("Setting saved '" + name + "=" + val + "'");
|
||||
return val;
|
||||
|
|
@ -896,6 +931,7 @@ const UI = {
|
|||
UI.updateSetting('logging');
|
||||
UI.updateSetting('reconnect');
|
||||
UI.updateSetting('reconnect_delay');
|
||||
UI.updateSetting('ignore_keys');
|
||||
|
||||
document.getElementById('noVNC_settings')
|
||||
.classList.add("noVNC_open");
|
||||
|
|
@ -1093,6 +1129,8 @@ const UI = {
|
|||
return;
|
||||
}
|
||||
|
||||
UI.wrapRfbSendKey();
|
||||
|
||||
UI.rfb.addEventListener("connect", UI.connectFinished);
|
||||
UI.rfb.addEventListener("disconnect", UI.disconnectFinished);
|
||||
UI.rfb.addEventListener("serververification", UI.serverVerify);
|
||||
|
|
@ -1593,6 +1631,25 @@ const UI = {
|
|||
UI.rfb.sendKey(keysym, code, down);
|
||||
},
|
||||
|
||||
wrapRfbSendKey() {
|
||||
if (!UI.rfb || !UI.rfb.sendKey || UI.rfb._ignoreKeysWrapped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalSendKey = UI.rfb.sendKey.bind(UI.rfb);
|
||||
|
||||
UI.rfb.sendKey = function (keysym, code, down) {
|
||||
if (UI.shouldIgnoreKey(code)) {
|
||||
Log.Debug("Key ignored: " + code);
|
||||
return;
|
||||
}
|
||||
|
||||
return originalSendKey(keysym, code, down);
|
||||
};
|
||||
|
||||
UI.rfb._ignoreKeysWrapped = true;
|
||||
},
|
||||
|
||||
// When normal keyboard events are left uncought, use the input events from
|
||||
// the keyboardinput element instead and generate the corresponding key events.
|
||||
// This code is required since some browsers on Android are inconsistent in
|
||||
|
|
@ -1864,6 +1921,170 @@ const UI = {
|
|||
selectbox.options.add(optn);
|
||||
},
|
||||
|
||||
supportedIgnoreKeys: [
|
||||
{ label: 'Escape', aliases: ['esc', 'escape'] },
|
||||
{ label: 'Tab', aliases: ['tab'] },
|
||||
{ label: 'Enter', aliases: ['enter', 'return'] },
|
||||
{ label: 'Delete', aliases: ['del', 'delete'] },
|
||||
{ label: 'Backspace', aliases: ['bs', 'backspace'] },
|
||||
{ label: 'ControlLeft', aliases: ['ctrl', 'ctl', 'controlleft'] },
|
||||
{ label: 'AltLeft', aliases: ['alt', 'altleft'] },
|
||||
{ label: 'MetaLeft', aliases: ['win', 'cmd', 'super', 'metaleft'] },
|
||||
],
|
||||
|
||||
buildIgnoreKeysTooltipText() {
|
||||
return `<ul class="noVNC_tooltip_list">` +
|
||||
UI.supportedIgnoreKeys
|
||||
.map(({ label, aliases }) => {
|
||||
if (!aliases.length) {
|
||||
return `<li>${label}</li>`;
|
||||
}
|
||||
return `<li>${label} (${aliases.join(', ')})</li>`;
|
||||
})
|
||||
.join('') +
|
||||
`</ul>`;
|
||||
},
|
||||
|
||||
buildIgnoreKeysPlaceholder() {
|
||||
return UI.supportedIgnoreKeys
|
||||
.map(({ aliases, label }) => aliases[0] || label)
|
||||
.slice(0, 4)
|
||||
.join(', ') + '...';
|
||||
},
|
||||
|
||||
initIgnoreKeysTooltip() {
|
||||
const input = document.getElementById('noVNC_setting_ignore_keys');
|
||||
if (!input) return;
|
||||
|
||||
const tooltip = document.getElementById('noVNC_ignore_keys_tooltip');
|
||||
const button = document.getElementById('noVNC_ignore_keys_help_button');
|
||||
|
||||
if (!tooltip || !button) return;
|
||||
|
||||
if (!input.placeholder) {
|
||||
input.placeholder = UI.buildIgnoreKeysPlaceholder();
|
||||
}
|
||||
|
||||
if (!tooltip.dataset.examplesAppended) {
|
||||
tooltip.innerHTML += UI.buildIgnoreKeysTooltipText();
|
||||
tooltip.dataset.examplesAppended = 'true';
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
tooltip.classList.add('noVNC_open');
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
tooltip.classList.remove('noVNC_open');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
const toggle = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (tooltip.classList.contains('noVNC_open')) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
};
|
||||
|
||||
if (!button.dataset.tooltipBound) {
|
||||
button.addEventListener('mouseenter', show);
|
||||
button.addEventListener('mouseleave', hide);
|
||||
button.addEventListener('focus', show);
|
||||
button.addEventListener('blur', hide);
|
||||
button.addEventListener('click', toggle);
|
||||
|
||||
tooltip.addEventListener('mouseenter', show);
|
||||
tooltip.addEventListener('mouseleave', hide);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!button.contains(event.target) && !tooltip.contains(event.target)) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
button.dataset.tooltipBound = 'true';
|
||||
}
|
||||
},
|
||||
|
||||
parseIgnoredKeys() {
|
||||
const raw = UI.getSetting('ignore_keys');
|
||||
if (!raw) return new Set();
|
||||
|
||||
return new Set(
|
||||
raw.split(',')
|
||||
.map(k => k.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
},
|
||||
|
||||
normalizeIgnoreKey(value) {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
|
||||
for (const { label, aliases } of UI.supportedIgnoreKeys) {
|
||||
const canonical = label.toLowerCase();
|
||||
|
||||
if (canonical === normalized) {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
if (alias.toLowerCase() === normalized) {
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
},
|
||||
|
||||
shouldIgnoreKey: (code) => {
|
||||
const ignored = UI.parseIgnoredKeys();
|
||||
if (ignored.size === 0) return false;
|
||||
|
||||
const codeCanonical = UI.normalizeIgnoreKey(code);
|
||||
if (!codeCanonical) return false;
|
||||
|
||||
for (const key of ignored) {
|
||||
if (UI.normalizeIgnoreKey(key) === codeCanonical) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
validateIgnoreKeysInput() {
|
||||
const input = document.getElementById('noVNC_setting_ignore_keys');
|
||||
if (!input) return true;
|
||||
|
||||
const tokens = input.value
|
||||
.split(',')
|
||||
.map(k => k.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
input.classList.remove('noVNC_invalid');
|
||||
return true;
|
||||
}
|
||||
|
||||
const validSet = new Set(
|
||||
UI.supportedIgnoreKeys.map(k => k.label.toLowerCase())
|
||||
);
|
||||
|
||||
const isValid = tokens.every((k) => {
|
||||
const normalized = UI.normalizeIgnoreKey(k);
|
||||
return validSet.has(normalized);
|
||||
});
|
||||
|
||||
input.classList.toggle('noVNC_invalid', !isValid);
|
||||
return isValid;
|
||||
},
|
||||
|
||||
/* ------^-------
|
||||
* /MISC
|
||||
* ==============
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ module.exports = (config) => {
|
|||
{ pattern: 'node_modules/sinon-chai/**', included: false },
|
||||
// modules to test
|
||||
{ pattern: 'app/localization.js', included: false, type: 'module' },
|
||||
{ pattern: 'app/ui.js', included: false, type: 'module' },
|
||||
{ pattern: 'app/wakelock.js', included: false, type: 'module' },
|
||||
{ pattern: 'app/webutil.js', included: false, type: 'module' },
|
||||
{ pattern: 'core/**/*.js', included: false, type: 'module' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import UI from '../app/ui.js';
|
||||
import * as WebUtil from '../app/webutil.js';
|
||||
|
||||
describe('UI', function () {
|
||||
"use strict";
|
||||
|
||||
describe('Ignore Keys Feature', function () {
|
||||
let originalSupportedIgnoreKeys;
|
||||
|
||||
beforeEach(async function () {
|
||||
await WebUtil.initSettings();
|
||||
|
||||
// Save original reference
|
||||
originalSupportedIgnoreKeys = UI.supportedIgnoreKeys;
|
||||
|
||||
// Clone + remove one key (MetaLeft) for testing
|
||||
UI.supportedIgnoreKeys = UI.supportedIgnoreKeys.filter(
|
||||
k => k.label !== 'MetaLeft'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
UI.rfb = null;
|
||||
|
||||
// Restore original list
|
||||
UI.supportedIgnoreKeys = originalSupportedIgnoreKeys;
|
||||
|
||||
WebUtil.eraseSetting('ignore_keys');
|
||||
});
|
||||
|
||||
describe('shouldIgnoreKey()', function () {
|
||||
it('returns false for removed keys', function () {
|
||||
WebUtil.setSetting('ignore_keys', 'cmd,win');
|
||||
|
||||
expect(UI.shouldIgnoreKey('MetaLeft')).to.be.false;
|
||||
});
|
||||
|
||||
it('still works for remaining keys', function () {
|
||||
WebUtil.setSetting('ignore_keys', 'esc,ctrl');
|
||||
|
||||
expect(UI.shouldIgnoreKey('Escape')).to.be.true;
|
||||
expect(UI.shouldIgnoreKey('ControlLeft')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
15
vnc.html
15
vnc.html
|
|
@ -220,6 +220,21 @@
|
|||
</label>
|
||||
</li>
|
||||
<li><hr></li>
|
||||
<li class="noVNC_setting">
|
||||
<div class="noVNC_setting_with_help">
|
||||
<label for="noVNC_setting_ignore_keys">Ignored Keys:</label>
|
||||
|
||||
<button id="noVNC_ignore_keys_help_button" type="button">?</button>
|
||||
|
||||
<div id="noVNC_ignore_keys_tooltip"
|
||||
class="noVNC_tooltip">
|
||||
Comma-separated list<br/>of keys to ignore.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input id="noVNC_setting_ignore_keys" type="text">
|
||||
</li>
|
||||
<li><hr></li>
|
||||
<li>
|
||||
<label>
|
||||
<input id="noVNC_setting_view_clip" type="checkbox"
|
||||
|
|
|
|||
Loading…
Reference in New Issue