+
-
-
- {{ failureTitle }}
-
-
- {{ statusDetail }}
-
-
-
-
+
+
-
- {{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }}
-
-
- {{ statusDetail }}
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.overlay.connecting', { name: vmName }) }}
+
+ {{ t('harvester.virtualMachine.detail.console.overlay.attempt', { retryTimes, maximumRetryTimes }) }}
+
+ {{ statusDetail }}
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.overlay.reconnectNow') }}
+
-
-
- {{ t('harvester.virtualMachine.detail.console.takeOver.title') }}
-
+ {{ t('harvester.virtualMachine.detail.console.takeOver.confirm') }}
-
- {{ t('harvester.virtualMachine.detail.console.takeOver.confirm') }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
+
-
-
-
-
-
-
-
-
+
diff --git a/pkg/harvester/components/novnc/NovncConsoleCustomKeys.vue b/pkg/harvester/components/novnc/NovncConsoleCustomKeys.vue
index 426ba896..5f0e50f4 100644
--- a/pkg/harvester/components/novnc/NovncConsoleCustomKeys.vue
+++ b/pkg/harvester/components/novnc/NovncConsoleCustomKeys.vue
@@ -1,261 +1,355 @@
-
-
-
-
+
+ {{ error }}
+
-
-
-
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.description') }}
+
-
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.new') }}
+
-
-
+ {{ t('harvester.virtualMachine.detail.console.customKeys.combination') }}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {{ t('harvester.virtualMachine.detail.console.customKeys.duplicate') }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.reserved') }}
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.clear') }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.add') }}
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.saved', { count: shortcuts.length }) }}
+
+
+ -
+ {{ shortcut.name || shortcut.labels.join('+') }}
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.done') }}
+
-
+
diff --git a/pkg/harvester/components/novnc/NovncConsoleItem.vue b/pkg/harvester/components/novnc/NovncConsoleItem.vue
deleted file mode 100644
index ff301df0..00000000
--- a/pkg/harvester/components/novnc/NovncConsoleItem.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-
-
- -
-
- {{ item.label }}
-
-
-
-
-
-
- {{ item.label }}
-
-
-
-
-
diff --git a/pkg/harvester/components/novnc/NovncConsoleWrapper.vue b/pkg/harvester/components/novnc/NovncConsoleWrapper.vue
index 43155a17..fff2cede 100644
--- a/pkg/harvester/components/novnc/NovncConsoleWrapper.vue
+++ b/pkg/harvester/components/novnc/NovncConsoleWrapper.vue
@@ -1,31 +1,36 @@
+
+
+
+
+
+
diff --git a/pkg/harvester/components/novnc/NovncKeyRecorder.vue b/pkg/harvester/components/novnc/NovncKeyRecorder.vue
new file mode 100644
index 00000000..d7572753
--- /dev/null
+++ b/pkg/harvester/components/novnc/NovncKeyRecorder.vue
@@ -0,0 +1,232 @@
+
+
+
+
+ {{ announcement }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.recorder.recording') }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.recorder.recordingStatus') }}
+
+
+
+
+
+
+ {{ label }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.recorder.again') }}
+
+
+ {{ t('harvester.virtualMachine.detail.console.customKeys.recorder.empty') }}
+
+
+
+
diff --git a/pkg/harvester/components/novnc/NovncShortcutKeysMenu.vue b/pkg/harvester/components/novnc/NovncShortcutKeysMenu.vue
new file mode 100644
index 00000000..48c33a6a
--- /dev/null
+++ b/pkg/harvester/components/novnc/NovncShortcutKeysMenu.vue
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.shortcutKeys') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pkg/harvester/components/novnc/NovncStatusCard.vue b/pkg/harvester/components/novnc/NovncStatusCard.vue
new file mode 100644
index 00000000..441cdb2f
--- /dev/null
+++ b/pkg/harvester/components/novnc/NovncStatusCard.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ detail }}
+
+
+
+
+
+
+
+
diff --git a/pkg/harvester/components/novnc/NovncViewOptions.vue b/pkg/harvester/components/novnc/NovncViewOptions.vue
new file mode 100644
index 00000000..a09a5fb9
--- /dev/null
+++ b/pkg/harvester/components/novnc/NovncViewOptions.vue
@@ -0,0 +1,302 @@
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.viewOptions.scaling') }}
+
+
+ {{ t(`harvester.virtualMachine.detail.console.viewOptions.${ isManual ? 'manualHelp' : 'automaticHelp' }`) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('harvester.virtualMachine.detail.console.viewOptions.reset') }}
+
+
+
+
+
diff --git a/pkg/harvester/components/novnc/ZoomableRFB.js b/pkg/harvester/components/novnc/ZoomableRFB.js
index ed40c023..c3351582 100644
--- a/pkg/harvester/components/novnc/ZoomableRFB.js
+++ b/pkg/harvester/components/novnc/ZoomableRFB.js
@@ -1,5 +1,12 @@
import RFB from '@novnc/novnc/core/rfb';
+export const MINIMUM_ZOOM = 50;
+export const MAXIMUM_ZOOM = 200;
+export const ZOOM_STEP = 10;
+
+// The focus ring and the gap before it, drawn outside the canvas.
+const FOCUS_RING_WIDTH = 4;
+
export function normalizeZoom(value) {
const percent = Number(value);
@@ -7,7 +14,7 @@ export function normalizeZoom(value) {
return 100;
}
- return Math.min(200, Math.max(50, Math.round(percent / 10) * 10));
+ return Math.min(MAXIMUM_ZOOM, Math.max(MINIMUM_ZOOM, Math.round(percent / ZOOM_STEP) * ZOOM_STEP));
}
export default class ZoomableRFB extends RFB {
@@ -20,12 +27,47 @@ export default class ZoomableRFB extends RFB {
this._updateScale();
}
+ // The screen takes all the room it has, without a focus ring around it.
+ get edgeToEdge() {
+ return !!this._edgeToEdge;
+ }
+
+ set edgeToEdge(value) {
+ this._edgeToEdge = value;
+ this._updateScale();
+ }
+
_updateScale() {
if (this.scaleViewport) {
- super._updateScale();
+ // Fit inside the focus ring the console draws around the canvas, instead of under it.
+ const size = this._screenSize();
+ const inset = this.edgeToEdge ? 0 : 2 * FOCUS_RING_WIDTH;
+
+ this._display.autoscale(size.w - inset, size.h - inset);
+ this._fixScrollbars();
} else {
this._display.scale = this.zoomPercent / 100;
this._fixScrollbars();
}
}
+
+ // noVNC only stops a click on the screen, which Firefox delivers there (others to the page, past
+ // noVNC's mouse capture). An open menu would never see the click outside it and would stay open.
+ _handleMouse(ev) {
+ if (ev.type !== 'click') {
+ super._handleMouse(ev);
+ }
+ }
+
+ // noVNC hides the mouse over the screen until the guest sends a pointer image, and a text console
+ // or a guest that draws its own pointer never does. Show the system pointer unless one arrives.
+ _refreshCursor() {
+ super._refreshCursor();
+
+ const hasGuestPointer = this._cursorImage.rgbaPixels.some((value, index) => index % 4 === 3 && value > 0);
+
+ if (!hasGuestPointer) {
+ this._canvas.style.cursor = 'default';
+ }
+ }
}
diff --git a/pkg/harvester/components/novnc/__tests__/NovncConnectionStatus.test.js b/pkg/harvester/components/novnc/__tests__/NovncConnectionStatus.test.js
new file mode 100644
index 00000000..8704b645
--- /dev/null
+++ b/pkg/harvester/components/novnc/__tests__/NovncConnectionStatus.test.js
@@ -0,0 +1,19 @@
+import { shallowMount } from '@vue/test-utils';
+import { RcButton } from '@components/RcButton';
+import NovncConnectionStatus from '../NovncConnectionStatus.vue';
+
+jest.mock('../ZoomableRFB', () => ({}));
+
+describe('component: NovncConnectionStatus', () => {
+ // A plain button takes the global 40px button height, taller than the 32px pill around it.
+ it('should offer Reconnect as an RcButton when the console is disconnected', () => {
+ const wrapper = shallowMount(NovncConnectionStatus, { props: { status: 'disconnected', label: 'Disconnected' } });
+ const reconnect = wrapper.findComponent(RcButton);
+
+ expect(reconnect.attributes('data-testid')).toBe('vnc-connection-reconnect');
+
+ reconnect.vm.$emit('click');
+
+ expect(wrapper.emitted('reconnect')).toHaveLength(1);
+ });
+});
diff --git a/pkg/harvester/components/novnc/__tests__/NovncConsole.test.js b/pkg/harvester/components/novnc/__tests__/NovncConsole.test.js
index c85b4701..e453ff85 100644
--- a/pkg/harvester/components/novnc/__tests__/NovncConsole.test.js
+++ b/pkg/harvester/components/novnc/__tests__/NovncConsole.test.js
@@ -97,6 +97,36 @@ describe('component: NovncConsole', () => {
lastRFB().emit('disconnect');
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.UNAVAILABLE);
+ expect(wrapper.vm.canTakeOver).toBe(false);
+ });
+
+ it('should retry, not report in use, when a VM that just started refuses the first connection', async() => {
+ const wrapper = mountConsole({ startedAt: Date.now() });
+
+ await jest.runOnlyPendingTimersAsync();
+ lastRFB().emit('disconnect');
+
+ expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
+ expect(wrapper.vm.failure).toBe('');
+ });
+
+ it('should keep counting retries when Reconnect now skips the wait', async() => {
+ const wrapper = mountConsole();
+
+ await jest.runOnlyPendingTimersAsync();
+ lastRFB().emit('connect');
+ lastRFB().emit('disconnect');
+ expect(wrapper.vm.retryTimes).toBe(1);
+
+ wrapper.vm.retryNow();
+
+ expect(wrapper.vm.retryTimes).toBe(1);
+ expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
+
+ // Still held by the other session: back to waiting, not the in-use failure.
+ lastRFB().emit('disconnect');
+ expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
+ expect(wrapper.vm.retryTimes).toBe(2);
});
it('should only take over after the modal is confirmed', async() => {
@@ -152,6 +182,20 @@ describe('component: NovncConsole', () => {
expect(view.classes()).not.toContain('is-focused');
});
+ it('should drop the focus ring and its room when the screen goes edge to edge', async() => {
+ const wrapper = mountConsole();
+ const view = wrapper.find('.novnc-screen');
+
+ await jest.runOnlyPendingTimersAsync();
+ expect(view.classes()).not.toContain('is-edge-to-edge');
+ expect(lastRFB().edgeToEdge).toBe(false);
+
+ await wrapper.setProps({ settings: { edgeToEdge: true } });
+
+ expect(view.classes()).toContain('is-edge-to-edge');
+ expect(lastRFB().edgeToEdge).toBe(true);
+ });
+
describe('when the cluster does not support preserveSession', () => {
it('should connect without the parameter and offer no take over', async() => {
const wrapper = mountConsole({ preserveSession: false });
diff --git a/pkg/harvester/components/novnc/__tests__/NovncConsoleCustomKeys.test.js b/pkg/harvester/components/novnc/__tests__/NovncConsoleCustomKeys.test.js
index 2c323fdd..81c5bdd8 100644
--- a/pkg/harvester/components/novnc/__tests__/NovncConsoleCustomKeys.test.js
+++ b/pkg/harvester/components/novnc/__tests__/NovncConsoleCustomKeys.test.js
@@ -1,92 +1,161 @@
-import { shallowMount } from '@vue/test-utils';
-import Banner from '@components/Banner/Banner.vue';
+import { reactive } from 'vue';
+import { flushPromises, mount, shallowMount } from '@vue/test-utils';
+import { RcButton } from '@components/RcButton';
import NovncConsoleCustomKeys from '../NovncConsoleCustomKeys.vue';
+import { SHORTCUTS_PREFERENCE, SHORTCUT_NAMES_PREFERENCE } from '../shortcuts';
-const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
+const key = (value, code) => ({ key: value, code });
-const key = (value) => ({ key: value, code: value });
+const CTRL_F2 = [key('Control', 'ControlLeft'), key('F2', 'F2')];
+const CTRL_SPACE = [key('Control', 'ControlLeft'), key(' ', 'Space')];
+const ALT_T = [key('Alt', 'AltLeft'), key('t', 'KeyT')];
-const CTRL_F2 = [key('Control'), key('F2')];
-const CTRL_SPACE = [key('Control'), key(' ')];
-const ALT_T = [key('Alt'), key('t')];
-
-const mountKeys = (saved = []) => {
- const preference = {
- data: { [PREFERED_SHORTCUT_KEYS]: JSON.stringify(saved) },
+// `rendered` mounts the buttons and the recorder for real, in the document, so the focus can be checked.
+const mountKeys = (saved = [], { rendered = false } = {}) => {
+ // Reactive like the store's, so the list follows what is saved.
+ const preference = reactive({
+ data: { [SHORTCUTS_PREFERENCE]: JSON.stringify(saved) },
save: jest.fn().mockResolvedValue(),
- };
+ });
- const wrapper = shallowMount(NovncConsoleCustomKeys, {
- global: {
+ const wrapper = (rendered ? mount : shallowMount)(NovncConsoleCustomKeys, {
+ props: { show: true },
+ attachTo: rendered ? document.body : undefined,
+ global: {
mocks: { $store: { getters: { 'management/all': () => [preference] } } },
- stubs: { ModalWithCard: { template: '
' } },
+ stubs: {
+ RcModal: { template: '
' }, Banner: { template: '
' }, LabeledInput: true
+ },
},
});
return { wrapper, preference };
};
-const listed = (wrapper) => wrapper.find('.displayed-banners').findAllComponents(Banner);
+const listed = (wrapper) => wrapper.findAll('[data-testid="vnc-custom-shortcut"]');
-const closeBanner = async(wrapper, index) => {
- listed(wrapper)[index].vm.$emit('close');
- await wrapper.vm.$nextTick();
+const removeShortcut = async(wrapper, index) => {
+ listed(wrapper)[index].findComponent(RcButton).vm.$emit('click');
+ await flushPromises();
};
-const savedAfterDone = async(wrapper, preference) => {
- await wrapper.vm.saveKeys(jest.fn());
-
- return JSON.parse(preference.data[PREFERED_SHORTCUT_KEYS]);
-};
+const savedKeys = (preference) => JSON.parse(preference.data[SHORTCUTS_PREFERENCE]);
describe('component: NovncConsoleCustomKeys', () => {
- it('should offer to remove every listed shortcut', () => {
+ it('should offer to remove every saved shortcut', () => {
const { wrapper } = mountKeys([CTRL_F2, ALT_T]);
- expect(listed(wrapper).map((banner) => banner.props('closable'))).toStrictEqual([true, true]);
+ expect(listed(wrapper).map((item) => item.findComponent(RcButton).exists())).toStrictEqual([true, true]);
});
- it('should save without the shortcut that was removed', async() => {
+ it('should save the list without the shortcut that was removed', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
- await closeBanner(wrapper, 0);
+ await removeShortcut(wrapper, 0);
- expect(listed(wrapper)).toHaveLength(1);
- expect(preference.save).not.toHaveBeenCalled();
-
- await expect(savedAfterDone(wrapper, preference)).resolves.toStrictEqual([ALT_T]);
expect(preference.save).toHaveBeenCalledTimes(1);
+ expect(savedKeys(preference)).toStrictEqual([ALT_T]);
+ expect(listed(wrapper)).toHaveLength(1);
});
it('should remove a shortcut with the Space key in it', async() => {
const { wrapper, preference } = mountKeys([CTRL_SPACE, ALT_T]);
- await closeBanner(wrapper, 0);
+ await removeShortcut(wrapper, 0);
- await expect(savedAfterDone(wrapper, preference)).resolves.toStrictEqual([ALT_T]);
+ expect(savedKeys(preference)).toStrictEqual([ALT_T]);
});
- it('should tell a shortcut that was just added from the saved ones', async() => {
- const { wrapper, preference } = mountKeys([CTRL_F2]);
-
- wrapper.vm.keysRecord = ALT_T;
- wrapper.vm.addShortcutKey();
- await wrapper.vm.$nextTick();
-
- // Just added shortcuts are listed first.
- await closeBanner(wrapper, 1);
-
- await expect(savedAfterDone(wrapper, preference)).resolves.toStrictEqual([ALT_T]);
- });
-
- it('should keep the saved shortcuts when it is closed without saving', async() => {
+ it('should keep the shortcut when removing it cannot be saved, and say so in the dialog', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
- await closeBanner(wrapper, 0);
- wrapper.vm.closeRecordingModal();
+ preference.save.mockRejectedValue(new Error('forbidden'));
+ await removeShortcut(wrapper, 0);
- expect(wrapper.emitted('close')).toHaveLength(1);
- expect(preference.save).not.toHaveBeenCalled();
- expect(JSON.parse(preference.data[PREFERED_SHORTCUT_KEYS])).toStrictEqual([CTRL_F2, ALT_T]);
+ expect(savedKeys(preference)).toStrictEqual([CTRL_F2, ALT_T]);
+ expect(listed(wrapper)).toHaveLength(2);
+ expect(wrapper.find('[data-testid="vnc-custom-keys-error"]').text()).toBe('%harvester.virtualMachine.detail.console.customKeys.saveError%: forbidden');
+
+ await wrapper.setProps({ show: false });
+
+ expect(wrapper.find('[data-testid="vnc-custom-keys-error"]').exists()).toBe(false);
+ });
+
+ it('should not start a second save while one is running', async() => {
+ const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
+
+ wrapper.vm.remove(0);
+ wrapper.vm.remove(1);
+ await flushPromises();
+
+ expect(preference.save).toHaveBeenCalledTimes(1);
+ });
+
+ it('should keep the focus on a delete button after a removal, and hand it to the recorder once the list is empty', async() => {
+ const { wrapper } = mountKeys([CTRL_F2, ALT_T], { rendered: true });
+ const deleteButtons = () => wrapper.findAll('[data-testid="vnc-custom-shortcut"] button');
+
+ deleteButtons()[1].element.focus();
+ await deleteButtons()[1].trigger('click');
+ await flushPromises();
+ expect(document.activeElement).toBe(deleteButtons()[0].element);
+
+ await deleteButtons()[0].trigger('click');
+ await flushPromises();
+ expect(document.activeElement).toBe(wrapper.find('[data-testid="vnc-key-recorder"]').element);
+
+ wrapper.unmount();
+ });
+
+ it('should give the recorder the focus after a shortcut is added', async() => {
+ const { wrapper } = mountKeys([CTRL_F2], { rendered: true });
+
+ wrapper.vm.keys = ALT_T;
+ await wrapper.vm.$nextTick();
+ wrapper.find('[data-testid="vnc-add-shortcut"]').element.focus();
+ await wrapper.find('[data-testid="vnc-add-shortcut"]').trigger('click');
+ await flushPromises();
+
+ expect(document.activeElement).toBe(wrapper.find('[data-testid="vnc-key-recorder"]').element);
+
+ wrapper.unmount();
+ });
+
+ it('should empty the form as soon as a shortcut is added, so it does not say it is already saved while it saves', async() => {
+ const { wrapper, preference } = mountKeys([CTRL_F2]);
+
+ preference.save.mockReturnValue(new Promise(() => {}));
+ wrapper.vm.keys = ALT_T;
+ wrapper.vm.add();
+ await wrapper.vm.$nextTick();
+
+ expect(listed(wrapper)).toHaveLength(2);
+ expect(wrapper.vm.keys).toStrictEqual([]);
+ expect(wrapper.find('.custom-keys__error').exists()).toBe(false);
+ });
+
+ it('should put the shortcut back in the form when it cannot be saved', async() => {
+ const { wrapper, preference } = mountKeys([CTRL_F2]);
+
+ preference.save.mockRejectedValue(new Error('forbidden'));
+ wrapper.vm.keys = ALT_T;
+ wrapper.vm.name = 'Task manager';
+ await wrapper.vm.add();
+
+ expect(listed(wrapper)).toHaveLength(1);
+ expect(wrapper.vm.keys).toStrictEqual(ALT_T);
+ expect(wrapper.vm.name).toBe('Task manager');
+ });
+
+ it('should save a new shortcut straight away, with its name', async() => {
+ const { wrapper, preference } = mountKeys([CTRL_F2]);
+
+ wrapper.vm.keys = ALT_T;
+ wrapper.vm.name = ' Task manager ';
+ await wrapper.vm.add();
+
+ expect(savedKeys(preference)).toStrictEqual([CTRL_F2, ALT_T]);
+ expect(JSON.parse(preference.data[SHORTCUT_NAMES_PREFERENCE])).toStrictEqual({ 'AltLeft+KeyT': 'Task manager' });
+ expect(wrapper.vm.keys).toStrictEqual([]);
});
});
diff --git a/pkg/harvester/components/novnc/__tests__/NovncConsoleWrapper.test.js b/pkg/harvester/components/novnc/__tests__/NovncConsoleWrapper.test.js
index d5d2fb3e..a0d3bd96 100644
--- a/pkg/harvester/components/novnc/__tests__/NovncConsoleWrapper.test.js
+++ b/pkg/harvester/components/novnc/__tests__/NovncConsoleWrapper.test.js
@@ -10,7 +10,12 @@ const consoleRef = {
focus: jest.fn(),
};
-const mountWrapper = () => {
+const CHECKBOX = { name: 'Checkbox' };
+
+const mountWrapper = ({ preferences = [], storage = {} } = {}) => {
+ // The console settings are saved there, so one test's would load in the next.
+ window.localStorage.clear();
+ Object.entries(storage).forEach(([key, value]) => window.localStorage.setItem(key, JSON.stringify(value)));
consoleRef.ctrlAltDelete.mockClear();
consoleRef.focus.mockClear();
@@ -24,7 +29,7 @@ const mountWrapper = () => {
mocks: {
$store: {
getters: {
- 'management/all': () => [],
+ 'management/all': () => preferences,
'harvester-common/getFeatureEnabled': () => true,
},
},
@@ -84,6 +89,106 @@ describe('component: NovncConsoleWrapper', () => {
expect(doSoftReboot).toHaveBeenCalledTimes(1);
});
+ it('should keep the shortcut menu open to use while keys cannot be sent, and say why', () => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.onStatusChanged({ status: 'reconnecting' });
+
+ expect(wrapper.vm.shortcutMenuProps.canSend).toBe(false);
+ expect(wrapper.vm.sendBlockedReason).toBe('%harvester.virtualMachine.detail.console.shortcutMenu.blockedDisconnected%');
+ // Without a user preference there is nothing to manage either, so only then is it disabled.
+ expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
+ });
+
+ it('should keep the shortcut menu enabled while disconnected when there are shortcuts to manage', () => {
+ const wrapper = mountWrapper({ preferences: [{ data: {} }] });
+
+ wrapper.vm.onStatusChanged({ status: 'reconnecting' });
+
+ expect(wrapper.vm.shortcutMenuProps.canSend).toBe(false);
+ expect(wrapper.vm.shortcutMenuProps.disabled).toBe(false);
+ });
+
+ it('should disable the shortcut menu, managing shortcuts included, while view only is on', () => {
+ const wrapper = mountWrapper({ preferences: [{ data: {} }] });
+
+ expect(wrapper.vm.shortcutMenuProps.disabled).toBe(false);
+
+ wrapper.vm.setViewOnly(true);
+
+ expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
+ });
+
+ it('should disable Ctrl+Alt+Del and Soft reboot while view only is on', async() => {
+ const wrapper = mountWrapper();
+ const doSoftReboot = jest.fn();
+ const button = (id) => wrapper.find(`[data-testid="${ id }"]`);
+
+ wrapper.vm.vmResource = { doSoftReboot, actions: { softreboot: 'url' } };
+ await wrapper.vm.$nextTick();
+
+ expect(button('vnc-soft-reboot').attributes('disabled')).toBe('false');
+ expect(button('vnc-ctrl-alt-del').attributes('disabled')).toBe('false');
+
+ wrapper.vm.setViewOnly(true);
+ await wrapper.vm.$nextTick();
+
+ expect(button('vnc-soft-reboot').attributes('disabled')).toBe('true');
+ expect(button('vnc-ctrl-alt-del').attributes('disabled')).toBe('true');
+
+ wrapper.vm.softReboot();
+ wrapper.vm.ctrlAltDelete();
+
+ expect(wrapper.vm.confirmAction).toBeNull();
+ expect(doSoftReboot).not.toHaveBeenCalled();
+ });
+
+ it('should say so under the console while view only is on', async() => {
+ const wrapper = mountWrapper();
+ const hint = () => wrapper.find('.console-area__hint').text();
+
+ expect(hint()).toBe('%harvester.virtualMachine.detail.console.hint%');
+
+ wrapper.vm.setViewOnly(true);
+ await wrapper.vm.$nextTick();
+
+ expect(hint()).toBe('%harvester.virtualMachine.detail.console.hintViewOnly%');
+ });
+
+ it('should not send keys while view only is on', () => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.setViewOnly(true);
+
+ expect(wrapper.vm.canSendKeys).toBe(false);
+ });
+
+ it('should switch view only with the checkbox, and not repeat it in the connection status', async() => {
+ const wrapper = mountWrapper();
+ const checkbox = () => wrapper.findComponent(CHECKBOX);
+
+ expect(checkbox().props('value')).toBe(false);
+
+ checkbox().vm.$emit('update:value', true);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.settings.viewOnly).toBe(true);
+ expect(checkbox().props('value')).toBe(true);
+ expect(wrapper.vm.statusLabel).toBe('%harvester.virtualMachine.detail.console.status.connected%');
+ });
+
+ it('should give the console the keyboard when view only is turned off, not when it is turned on', async() => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.setViewOnly(true);
+ await wrapper.vm.$nextTick();
+ expect(consoleRef.focus).not.toHaveBeenCalled();
+
+ wrapper.vm.setViewOnly(false);
+ await wrapper.vm.$nextTick();
+ expect(consoleRef.focus).toHaveBeenCalledTimes(1);
+ });
+
it('should not ask to send Ctrl+Alt+Del while keys cannot be sent', () => {
const wrapper = mountWrapper();
@@ -93,16 +198,124 @@ describe('component: NovncConsoleWrapper', () => {
expect(wrapper.vm.confirmAction).toBeNull();
});
- it('should put the whole page in full screen, so that dialogs rendered outside the console show', async() => {
- const wrapper = mountWrapper();
- const requestFullscreen = jest.fn().mockResolvedValue();
+ describe('view settings', () => {
+ it('should not take what the previous console saved on its first open as a choice', () => {
+ const wrapper = mountWrapper({
+ storage: {
+ 'harvester-vnc-console-settings': {
+ viewMode: 'none', zoomPercent: 100, viewOnly: false, qualityLevel: 6, compressionLevel: 2
+ }
+ }
+ });
- document.documentElement.requestFullscreen = requestFullscreen;
+ expect(wrapper.vm.settings.viewMode).toBe('scale');
+ });
- await wrapper.vm.toggleFullscreen();
+ it('should carry view only over from the previous console, which saved it on only when the user turned it on', () => {
+ const wrapper = mountWrapper({
+ storage: {
+ 'harvester-vnc-console-settings': {
+ viewMode: 'none', zoomPercent: 150, viewOnly: true, qualityLevel: 9, compressionLevel: 0
+ }
+ }
+ });
- expect(requestFullscreen).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.settings).toMatchObject({
+ viewOnly: true, viewMode: 'scale', zoomPercent: 100
+ });
+ });
- delete document.documentElement.requestFullscreen;
+ it('should take view only from its own settings once it has saved them', () => {
+ const wrapper = mountWrapper({
+ storage: {
+ 'harvester-vnc-console-settings': { viewOnly: true },
+ 'harvester-vnc-console-view': { viewOnly: false },
+ }
+ });
+
+ expect(wrapper.vm.settings.viewOnly).toBe(false);
+ });
+
+ it('should save the settings only once the user changes one, and load them next time', async() => {
+ const wrapper = mountWrapper();
+
+ await wrapper.vm.$nextTick();
+ expect(window.localStorage).toHaveLength(0);
+
+ wrapper.vm.updateSettings({
+ ...wrapper.vm.settings, viewMode: 'none', zoomPercent: 150
+ });
+
+ const reopened = mountWrapper({ storage: { 'harvester-vnc-console-view': JSON.parse(window.localStorage.getItem('harvester-vnc-console-view')) } });
+
+ expect(reopened.vm.settings).toMatchObject({ viewMode: 'none', zoomPercent: 150 });
+ });
+
+ it('should keep view only on when the View panel resets its settings', () => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.setViewOnly(true);
+ wrapper.vm.updateSettings({ ...wrapper.vm.settings, zoomPercent: 150 });
+ wrapper.vm.resetSettings();
+
+ expect(wrapper.vm.settings).toMatchObject({ viewOnly: true, zoomPercent: 100 });
+ expect(wrapper.vm.rfbSettings.viewOnly).toBe(true);
+ });
+ });
+
+ describe('in full screen', () => {
+ it('should fill the window whatever the saved view mode, and go back to it afterwards', () => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.updateSettings({ ...wrapper.vm.settings, viewMode: 'none' });
+ expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: false, edgeToEdge: false });
+
+ wrapper.vm.isFullscreen = true;
+ expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: true, edgeToEdge: true });
+
+ wrapper.vm.isFullscreen = false;
+ expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: false, edgeToEdge: false });
+ });
+
+ it('should leave the view only checkbox out of the bar, and keep view only as it was', async() => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.setViewOnly(true);
+ wrapper.vm.isFullscreen = true;
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(CHECKBOX).exists()).toBe(false);
+ expect(wrapper.vm.rfbSettings.viewOnly).toBe(true);
+ expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
+
+ wrapper.vm.isFullscreen = false;
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(CHECKBOX).props('value')).toBe(true);
+ });
+
+ it('should leave Ctrl+Alt+Del out of the bar', async() => {
+ const wrapper = mountWrapper();
+
+ wrapper.vm.isFullscreen = true;
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="vnc-fullscreen-bar"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="vnc-ctrl-alt-del"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="vnc-fullscreen-ctrl-alt-del"]').exists()).toBe(false);
+ });
+
+ it('should put the whole page in full screen, so that dialogs rendered outside the console show', async() => {
+ const wrapper = mountWrapper();
+ const requestFullscreen = jest.fn().mockResolvedValue();
+
+ document.documentElement.requestFullscreen = requestFullscreen;
+
+ await wrapper.vm.toggleFullscreen();
+
+ expect(requestFullscreen).toHaveBeenCalledTimes(1);
+
+ delete document.documentElement.requestFullscreen;
+ });
});
});
diff --git a/pkg/harvester/components/novnc/__tests__/NovncKeyRecorder.test.js b/pkg/harvester/components/novnc/__tests__/NovncKeyRecorder.test.js
new file mode 100644
index 00000000..d13171b6
--- /dev/null
+++ b/pkg/harvester/components/novnc/__tests__/NovncKeyRecorder.test.js
@@ -0,0 +1,91 @@
+import { shallowMount } from '@vue/test-utils';
+import NovncKeyRecorder from '../NovncKeyRecorder.vue';
+
+const key = (k, code, extra = {}) => ({
+ key: k, code, keyCode: 0, location: 0, charCode: 0, repeat: false, preventDefault: jest.fn(), stopPropagation: jest.fn(), ...extra
+});
+
+describe('component: NovncKeyRecorder', () => {
+ it('should hold modifiers until another key ends the combination', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Control', 'ControlLeft'));
+ wrapper.vm.onKeydown(key('Shift', 'ShiftLeft'));
+ wrapper.vm.onKeydown(key('Control', 'ControlLeft', { repeat: true }));
+ wrapper.vm.onKeydown(key('Escape', 'Escape'));
+
+ expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['ControlLeft', 'ShiftLeft', 'Escape']);
+ expect(wrapper.vm.recording).toBe(false);
+ });
+
+ it('should keep the browser from acting on keys while recording', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+ const event = key('w', 'KeyW');
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(event);
+
+ expect(event.preventDefault).toHaveBeenCalledWith();
+ });
+
+ it('should record a modifier pressed and released on its own, such as the Windows key', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Meta', 'MetaLeft'));
+ wrapper.vm.onKeyup(key('Meta', 'MetaLeft'));
+
+ expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['MetaLeft']);
+ expect(wrapper.vm.recording).toBe(false);
+ });
+
+ it('should wait for the last modifier to be released before recording them', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Control', 'ControlLeft'));
+ wrapper.vm.onKeydown(key('Shift', 'ShiftLeft'));
+ wrapper.vm.onKeyup(key('Shift', 'ShiftLeft', { ctrlKey: true }));
+ expect(wrapper.emitted('update:value')).toBeUndefined();
+
+ wrapper.vm.onKeyup(key('Control', 'ControlLeft'));
+ expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['ControlLeft', 'ShiftLeft']);
+ });
+
+ it('should drop a combination of modifiers only when it loses focus', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Alt', 'AltLeft'));
+ wrapper.vm.stop();
+
+ expect(wrapper.emitted('update:value')).toBeUndefined();
+ expect(wrapper.vm.state).toBe('empty');
+ });
+
+ it('should stop recording on Esc or Tab alone, without recording them', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+ const tab = key('Tab', 'Tab');
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Escape', 'Escape'));
+ expect(wrapper.vm.recording).toBe(false);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(tab);
+ expect(wrapper.vm.recording).toBe(false);
+ expect(tab.preventDefault).not.toHaveBeenCalled();
+ expect(wrapper.emitted('update:value')).toBeUndefined();
+ });
+
+ it('should record a letter by its key, not the symbol a layout made of it', () => {
+ const wrapper = shallowMount(NovncKeyRecorder);
+
+ wrapper.vm.start();
+ wrapper.vm.onKeydown(key('Alt', 'AltLeft'));
+ wrapper.vm.onKeydown(key('©', 'KeyG'));
+
+ expect(wrapper.emitted('update:value')[0][0].map((K) => K.key)).toStrictEqual(['Alt', 'g']);
+ });
+});
diff --git a/pkg/harvester/components/novnc/__tests__/NovncShortcutKeysMenu.test.js b/pkg/harvester/components/novnc/__tests__/NovncShortcutKeysMenu.test.js
new file mode 100644
index 00000000..09deb413
--- /dev/null
+++ b/pkg/harvester/components/novnc/__tests__/NovncShortcutKeysMenu.test.js
@@ -0,0 +1,158 @@
+import { ref } from 'vue';
+import { mount, shallowMount } from '@vue/test-utils';
+import NovncShortcutKeysMenu, { ShortcutMenuBody } from '../NovncShortcutKeysMenu.vue';
+import {
+ keyLabel, normalizeShortcut, readShortcuts, shortcutLabels, writeShortcuts, SHORTCUTS_PREFERENCE, SHORTCUT_NAMES_PREFERENCE
+} from '../shortcuts';
+
+const mountMenu = (props = {}) => shallowMount(NovncShortcutKeysMenu, { props });
+
+describe('component: NovncShortcutKeysMenu', () => {
+ it('should send a key on its own when no modifier is held', () => {
+ const wrapper = mountMenu();
+
+ wrapper.vm.sendKey('F5');
+
+ expect(wrapper.emitted('send-keys')).toStrictEqual([[['F5']]]);
+ });
+
+ it('should send held modifiers with the next key and then release them', () => {
+ const wrapper = mountMenu();
+
+ wrapper.vm.toggleModifier('AltLeft', true);
+ wrapper.vm.toggleModifier('ControlLeft', true);
+ expect(wrapper.vm.heldCombo).toBe('Ctrl+Alt');
+
+ wrapper.vm.sendKey('F2');
+
+ expect(wrapper.emitted('send-keys')).toStrictEqual([[['ControlLeft', 'AltLeft', 'F2']]]);
+ expect(wrapper.vm.held).toStrictEqual([]);
+ });
+
+ it('should offer the keys that go with what is held, like the old menu', async() => {
+ const wrapper = mountMenu();
+ const keys = () => wrapper.vm.keyOptions.map((K) => K.key);
+
+ expect(keys()).toStrictEqual(['PrintScreen']);
+ expect(wrapper.vm.showFunctionKeys).toBe(true);
+
+ wrapper.vm.toggleModifier('ControlLeft', true);
+ expect(keys()).toStrictEqual(['KeyN', 'KeyT', 'KeyW', 'KeyY']);
+ expect(wrapper.vm.showFunctionKeys).toBe(false);
+
+ wrapper.vm.toggleModifier('AltLeft', true);
+ expect(keys()).toStrictEqual(['Delete']);
+ expect(wrapper.vm.showFunctionKeys).toBe(true);
+
+ wrapper.vm.toggleModifier('ControlLeft', false);
+ expect(keys()).toStrictEqual(['PrintScreen']);
+ expect(wrapper.vm.showFunctionKeys).toBe(true);
+ });
+
+ it('should send Ctrl+W from the menu', () => {
+ const wrapper = mountMenu();
+
+ wrapper.vm.toggleModifier('ControlLeft', true);
+ wrapper.vm.sendKey('KeyW');
+
+ expect(wrapper.emitted('send-keys')).toStrictEqual([[['ControlLeft', 'KeyW']]]);
+ });
+
+ it('should hand Ctrl+Alt+Del to the confirmation instead of sending it', () => {
+ const wrapper = mountMenu();
+
+ wrapper.vm.toggleModifier('ControlLeft', true);
+ wrapper.vm.toggleModifier('AltLeft', true);
+ wrapper.vm.sendKey('Delete');
+
+ expect(wrapper.emitted('ctrl-alt-del')).toHaveLength(1);
+ expect(wrapper.emitted('send-keys')).toBeUndefined();
+ expect(wrapper.vm.held).toStrictEqual([]);
+ });
+
+ it('should let held modifiers go when the menu closes without a key', () => {
+ const wrapper = mountMenu();
+
+ wrapper.vm.toggleModifier('ControlLeft', true);
+ wrapper.vm.onOpen(false);
+
+ expect(wrapper.vm.held).toStrictEqual([]);
+ });
+
+ it('should name saved shortcuts by their keys when they have no name', () => {
+ const wrapper = mountMenu({
+ shortcuts: [
+ { name: 'Open terminal', keys: [{ key: 'Control' }, { key: 'Alt' }, { key: 't' }] },
+ { name: '', keys: [{ key: 'Escape' }] },
+ ]
+ });
+
+ expect(wrapper.vm.items.map(({ name, label }) => [name, label])).toStrictEqual([
+ ['Open terminal', 'Ctrl+Alt+T'],
+ ['Esc', 'Esc'],
+ ]);
+ });
+
+ it('should give the arrow keys the items shown after a modifier swapped them', async() => {
+ const dropdownItems = ref([]);
+ const keys = ref(['PrintScreen']);
+ const wrapper = mount({
+ components: { ShortcutMenuBody },
+ setup: () => ({ keys }),
+ template: '
',
+ }, { global: { provide: { dropdownContext: { dropdownItems } } } });
+
+ keys.value = ['KeyN', 'KeyT'];
+ await wrapper.vm.$nextTick();
+ await wrapper.find('[dropdown-menu-item]').trigger('keydown', { key: 'ArrowDown' });
+
+ expect(dropdownItems.value.map((item) => item.textContent)).toStrictEqual(['KeyN', 'KeyT']);
+ });
+});
+
+describe('shortcuts', () => {
+ it('should read both the old list format and the named format', () => {
+ const keys = [{ key: 'Control', code: 'ControlLeft' }];
+
+ expect(normalizeShortcut(keys)).toStrictEqual({ name: '', keys });
+ expect(normalizeShortcut({ name: 'Task manager', keys })).toStrictEqual({ name: 'Task manager', keys });
+ expect(normalizeShortcut({ name: 'Broken' })).toStrictEqual({ name: 'Broken', keys: [] });
+ });
+
+ it('should label keys the way the console menus show them', () => {
+ expect(keyLabel('Control')).toBe('Ctrl');
+ expect(keyLabel('a')).toBe('A');
+ expect(keyLabel('F2')).toBe('F2');
+ expect(shortcutLabels({ keys: [{ key: 'Shift' }, { key: ' ' }] })).toStrictEqual(['Shift', 'Space']);
+ expect(shortcutLabels({ keys: [{ key: 'Alt', code: 'AltLeft' }, { key: '©', code: 'KeyG' }] })).toStrictEqual(['Alt', 'G']);
+ // Real keys of other layouts stay as typed: AZERTY comma on KeyM, Windows AltGr (Ctrl+Alt) letters.
+ expect(shortcutLabels({ keys: [{ key: 'Control', code: 'ControlLeft' }, { key: ',', code: 'KeyM' }] })).toStrictEqual(['Ctrl', ',']);
+ expect(shortcutLabels({ keys: [{ key: 'Control', code: 'ControlLeft' }, { key: 'Alt', code: 'AltRight' }, { key: 'ą', code: 'KeyA' }] })).toStrictEqual(['Ctrl', 'Alt', 'Ą']);
+ expect(shortcutLabels({ keys: [{ key: 'Alt', code: 'AltLeft' }, { key: 'Dead', code: 'KeyE' }] })).toStrictEqual(['Alt', 'E']);
+ });
+
+ it('should keep the key list in the format older versions read and store names apart', async() => {
+ const keys = [{ key: 'Control', code: 'ControlLeft' }, { key: 't', code: 'KeyT' }];
+ const preference = { data: {}, save: jest.fn().mockResolvedValue() };
+
+ await writeShortcuts(preference, [{ name: 'Open terminal', keys }, { name: '', keys: [{ key: 'F2', code: 'F2' }] }]);
+
+ expect(JSON.parse(preference.data[SHORTCUTS_PREFERENCE])).toStrictEqual([keys, [{ key: 'F2', code: 'F2' }]]);
+ expect(JSON.parse(preference.data[SHORTCUT_NAMES_PREFERENCE])).toStrictEqual({ 'ControlLeft+KeyT': 'Open terminal' });
+ expect(readShortcuts(preference).map((shortcut) => shortcut.name)).toStrictEqual(['Open terminal', '']);
+ });
+
+ it('should put the preference back when the save fails', async() => {
+ const saved = JSON.stringify([[{ key: 'F2', code: 'F2' }]]);
+ const preference = { data: { [SHORTCUTS_PREFERENCE]: saved }, save: jest.fn().mockRejectedValue(new Error('403')) };
+
+ await expect(writeShortcuts(preference, [])).rejects.toThrow('403');
+
+ expect(preference.data).toStrictEqual({ [SHORTCUTS_PREFERENCE]: saved });
+ });
+
+ it('should read nothing from a preference it cannot parse', () => {
+ expect(readShortcuts({ data: { [SHORTCUTS_PREFERENCE]: '{not json' } })).toStrictEqual([]);
+ expect(readShortcuts(undefined)).toStrictEqual([]);
+ });
+});
diff --git a/pkg/harvester/components/novnc/__tests__/NovncViewOptions.test.js b/pkg/harvester/components/novnc/__tests__/NovncViewOptions.test.js
new file mode 100644
index 00000000..b072003f
--- /dev/null
+++ b/pkg/harvester/components/novnc/__tests__/NovncViewOptions.test.js
@@ -0,0 +1,36 @@
+import { shallowMount } from '@vue/test-utils';
+import NovncViewOptions, { VIEW_MODE } from '../NovncViewOptions.vue';
+
+jest.mock('../ZoomableRFB', () => ({
+ MINIMUM_ZOOM: 50, MAXIMUM_ZOOM: 200, ZOOM_STEP: 10
+}));
+
+const settings = {
+ viewMode: VIEW_MODE.NONE, zoomPercent: 100, qualityLevel: 6, compressionLevel: 2
+};
+
+const mountOptions = (overrides = {}) => shallowMount(NovncViewOptions, { props: { settings: { ...settings, ...overrides } } });
+
+describe('component: NovncViewOptions', () => {
+ it('should emit the whole settings object with the changed value', () => {
+ const wrapper = mountOptions();
+
+ wrapper.vm.update('qualityLevel', 9);
+
+ expect(wrapper.emitted('update')).toStrictEqual([[{ ...settings, qualityLevel: 9 }]]);
+ });
+
+ it('should step the zoom and stop at its limits', () => {
+ const wrapper = mountOptions({ zoomPercent: 200 });
+
+ wrapper.vm.stepZoom(1);
+ wrapper.vm.stepZoom(-1);
+
+ expect(wrapper.emitted('update').map(([value]) => value.zoomPercent)).toStrictEqual([200, 190]);
+ });
+
+ it('should only treat the manual mode as manual', () => {
+ expect(mountOptions().vm.isManual).toBe(true);
+ expect(mountOptions({ viewMode: VIEW_MODE.SCALE }).vm.isManual).toBe(false);
+ });
+});
diff --git a/pkg/harvester/components/novnc/__tests__/ZoomableRFB.test.js b/pkg/harvester/components/novnc/__tests__/ZoomableRFB.test.js
new file mode 100644
index 00000000..b3bb741c
--- /dev/null
+++ b/pkg/harvester/components/novnc/__tests__/ZoomableRFB.test.js
@@ -0,0 +1,109 @@
+import ZoomableRFB from '../ZoomableRFB';
+
+const GUEST_POINTER = 'url(guest-pointer) 0 0, default';
+
+jest.mock('@novnc/novnc/core/rfb', () => ({
+ __esModule: true,
+ default: class {
+ constructor() {
+ this.scaleViewport = true;
+ this._canvas = { style: {} };
+ this._cursorImage = { rgbaPixels: new Uint8Array() };
+ this._display = { autoscale: jest.fn() };
+ }
+
+ _screenSize() {
+ return { w: 1000, h: 500 };
+ }
+
+ _fixScrollbars() {}
+
+ _handleMouse(ev) {
+ ev.stopPropagation();
+ }
+
+ // As noVNC does: nothing visible hides the mouse, an image is shown as the pointer.
+ _refreshCursor() {
+ this._canvas.style.cursor = this._cursorImage.rgbaPixels.length ? 'url(guest-pointer) 0 0, default' : 'none';
+ }
+ },
+}), { virtual: true });
+
+describe('zoomableRFB', () => {
+ describe('the pointer over the screen', () => {
+ it('should be the system one while the guest sends no pointer image', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb._refreshCursor();
+
+ expect(rfb._canvas.style.cursor).toBe('default');
+ });
+
+ it('should be the system one when the image the guest sends is fully transparent', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb._cursorImage = { rgbaPixels: new Uint8Array([255, 255, 255, 0, 0, 0, 0, 0]) };
+ rfb._refreshCursor();
+
+ expect(rfb._canvas.style.cursor).toBe('default');
+ });
+
+ it('should be the one the guest sends when it has visible pixels', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb._cursorImage = { rgbaPixels: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 255]) };
+ rfb._refreshCursor();
+
+ expect(rfb._canvas.style.cursor).toBe(GUEST_POINTER);
+ });
+
+ it('should go back to the system one when the guest stops sending an image', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb._cursorImage = { rgbaPixels: new Uint8Array([0, 0, 0, 255]) };
+ rfb._refreshCursor();
+ rfb._cursorImage = { rgbaPixels: [] };
+ rfb._refreshCursor();
+
+ expect(rfb._canvas.style.cursor).toBe('default');
+ });
+ });
+
+ describe('mouse events on the screen', () => {
+ it('should let a click through, so an open menu sees it as a click outside', () => {
+ const rfb = new ZoomableRFB();
+ const stopPropagation = jest.fn();
+
+ rfb._handleMouse({ type: 'click', stopPropagation });
+
+ expect(stopPropagation).not.toHaveBeenCalled();
+ });
+
+ it('should leave the others to noVNC', () => {
+ const rfb = new ZoomableRFB();
+ const stopPropagation = jest.fn();
+
+ rfb._handleMouse({ type: 'mousedown', stopPropagation });
+
+ expect(stopPropagation).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('the fitted screen', () => {
+ it('should keep room for the focus ring', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb._updateScale();
+
+ expect(rfb._display.autoscale).toHaveBeenLastCalledWith(992, 492);
+ });
+
+ it('should take all the room when it is edge to edge', () => {
+ const rfb = new ZoomableRFB();
+
+ rfb.edgeToEdge = true;
+
+ expect(rfb._display.autoscale).toHaveBeenLastCalledWith(1000, 500);
+ });
+ });
+});
diff --git a/pkg/harvester/components/novnc/shortcuts.js b/pkg/harvester/components/novnc/shortcuts.js
new file mode 100644
index 00000000..583bb7e9
--- /dev/null
+++ b/pkg/harvester/components/novnc/shortcuts.js
@@ -0,0 +1,118 @@
+// Saved custom shortcuts live in the Rancher user preference, which every Harvester UI the user opens
+// shares. The keys stay a list of recorded keydown events per shortcut, the format older versions read;
+// names go in a separate preference so those versions keep working.
+export const SHORTCUTS_PREFERENCE = 'prefered-shortcut-keys';
+export const SHORTCUT_NAMES_PREFERENCE = 'vnc-shortcut-names';
+
+const KEY_LABELS = {
+ Control: 'Ctrl',
+ Escape: 'Esc',
+ Delete: 'Del',
+ ' ': 'Space',
+};
+
+export const MODIFIER_KEYS = ['Control', 'Shift', 'Alt', 'Meta'];
+
+export function keyLabel(key = '') {
+ if (KEY_LABELS[key]) {
+ return KEY_LABELS[key];
+ }
+
+ return key.length === 1 ? key.toUpperCase() : key;
+}
+
+// macOS Option turns a letter into a symbol (Option+G gives ©, Option+E a dead key). The key that was
+// pressed is still in `code`, so in that case the letter is named after it. Only with Alt and no Ctrl:
+// AZERTY's comma on KeyM, Dvorak's punctuation or Cyrillic letters are real keys, and Windows AltGr
+// reports Ctrl+Alt with a real character.
+export function baseKey(K, combination = []) {
+ const letter = /^Key([A-Z])$/.exec(K?.code || '');
+
+ if (!letter || /^[a-z]$/i.test(K.key || '')) {
+ return K;
+ }
+
+ const held = combination.map((M) => M.key);
+ const isOptionSymbol = held.includes('Alt') && !held.includes('Control');
+
+ return K.key === 'Dead' || isOptionSymbol ? { ...K, key: letter[1].toLowerCase() } : K;
+}
+
+export function normalizeShortcut(item) {
+ if (Array.isArray(item)) {
+ return { name: '', keys: item };
+ }
+
+ return { name: item?.name || '', keys: Array.isArray(item?.keys) ? item.keys : [] };
+}
+
+export function shortcutLabels(shortcut) {
+ const keys = shortcut?.keys || [];
+
+ return keys.map((K) => keyLabel(baseKey(K, keys).key));
+}
+
+// Two shortcuts are the same when they press the same keys, whatever they are called.
+export function shortcutId(shortcut) {
+ return (shortcut?.keys || []).map((K) => K.code || K.key).join('+');
+}
+
+// Only what sending a shortcut needs, so a recorded KeyboardEvent can be stored.
+export function recordKey(event, combination = []) {
+ const {
+ key, keyCode, code, location, charCode
+ } = event;
+
+ return baseKey({
+ key, keyCode, code, location, charCode
+ }, combination);
+}
+
+function parse(value, fallback) {
+ try {
+ return value ? JSON.parse(value) : fallback;
+ } catch (err) {
+ return fallback;
+ }
+}
+
+export function readShortcuts(preference) {
+ const list = parse(preference?.data?.[SHORTCUTS_PREFERENCE], []);
+ const names = parse(preference?.data?.[SHORTCUT_NAMES_PREFERENCE], {});
+
+ return (Array.isArray(list) ? list : [])
+ .map(normalizeShortcut)
+ .filter((shortcut) => shortcut.keys.length)
+ .map((shortcut) => ({ ...shortcut, name: shortcut.name || names?.[shortcutId(shortcut)] || '' }));
+}
+
+export function writeShortcuts(preference, shortcuts) {
+ const names = {};
+
+ shortcuts.forEach((shortcut) => {
+ if (shortcut.name) {
+ names[shortcutId(shortcut)] = shortcut.name;
+ }
+ });
+
+ const previous = {
+ [SHORTCUTS_PREFERENCE]: preference.data[SHORTCUTS_PREFERENCE],
+ [SHORTCUT_NAMES_PREFERENCE]: preference.data[SHORTCUT_NAMES_PREFERENCE],
+ };
+
+ preference.data[SHORTCUTS_PREFERENCE] = JSON.stringify(shortcuts.map((shortcut) => shortcut.keys));
+ preference.data[SHORTCUT_NAMES_PREFERENCE] = JSON.stringify(names);
+
+ // The lists read straight from the preference, so a failed save must not leave the change showing.
+ return preference.save().catch((err) => {
+ Object.entries(previous).forEach(([key, value]) => {
+ if (value === undefined) {
+ delete preference.data[key];
+ } else {
+ preference.data[key] = value;
+ }
+ });
+
+ throw err;
+ });
+}
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml
index 6542a342..ea117af5 100644
--- a/pkg/harvester/l10n/en-us.yaml
+++ b/pkg/harvester/l10n/en-us.yaml
@@ -1061,58 +1061,100 @@ harvester:
from: Generated from
down: No events in the past hour
console:
- down: This virtual machine is down. Please start it to access its console.
- shortcutKeys: Shortcut Keys
- customShortcutKeys: Custom Shortcut Keys
- management: Management Shortcut Keys
- record:
- start: Record
- recording: Recording
- stop: Stop Recording
- tips: Press the record button to capture your keyboard inputs.
- send: Send
- preferredKeys: Preferred Custom Shortcut Keys
- settings:
- label: Settings
- viewMode:
- label: Display
- scale: Scale the display to fit the window
- none: Manual zoom
- zoom: Zoom
- viewOnly: View only
- quality: JPEG quality
- compression: Compression level
+ notRunning:
+ title: This virtual machine is not running
+ detail: Start it to use its console.
+ startingTitle: This virtual machine is starting
+ startingDetail: The console connects once it is running.
+ shortcutKeys: Shortcut keys
+ softReboot: Soft reboot
+ view: View
+ shortcutMenu:
+ yourShortcuts: Your shortcuts
+ holdModifiers: Hold modifiers
+ holdHint: 'The next key you pick is sent as {combo}+key.'
+ keys: Keys
+ printScreen: Print Screen
+ functionKeys: Function keys
+ manage: Manage custom shortcuts…
+ blockedDisconnected: Keys can be sent once the console is connected.
+ hint: Clicking the console captures your keyboard. To send a shortcut, use the Shortcut keys menu.
+ hintViewOnly: '"View only" mode is on. In this mode, your mouse and keyboard are not sent to the VM.'
+ viewOnly: View only
+ connection:
+ menuLabel: Connection
+ description: Connected to {name}. If the screen stops updating, reconnecting usually fixes it.
+ customKeys:
+ title: Custom shortcuts
+ description: Save key combinations you send often. They appear at the top of the Shortcut keys menu.
+ new: New shortcut
+ combination: Key combination
+ reserved: Combinations your browser or OS reserves, like Ctrl+Alt+Del or Ctrl+W, can’t be recorded. Send them from the Shortcut keys menu instead, by holding Ctrl or Alt there.
+ duplicate: This key combination is already saved.
+ name: Name (optional)
+ clear: Clear
+ add: Add shortcut
+ saved: 'Saved shortcuts ({count})'
+ delete: 'Delete {name}'
+ done: Done
+ saveError: Unable to save the custom shortcuts
+ recorder:
+ empty: Click here, then press a key combination
+ recording: Press a key combination…
+ recordingStatus: Recording
+ again: Click to record again
+ viewOptions:
+ scaling: Scaling
+ automatic: Automatic
+ manual: Manual
+ automaticHelp: The screen scales to fit the window.
+ manualHelp: You set the zoom. Scroll to see parts of the screen that do not fit.
+ zoom: Zoom level
+ zoomIn: Zoom in
+ zoomOut: Zoom out
+ levelOf: '{value} of {max}'
+ qualityLevel:
+ label: JPEG quality
+ help: Higher quality looks sharper and uses more bandwidth.
+ compressionLevel:
+ label: Compression
+ help: Higher compression saves bandwidth but uses more CPU.
+ reset: Reset to defaults
fullscreen:
- enter: Full Screen
- exit: Exit Full Screen
+ enter: Full screen
+ exit: Exit full screen
ctrlAltDel: Ctrl+Alt+Del
confirm:
ctrlAltDel:
- title: Send Ctrl+Alt+Del?
- message: Windows opens its sign-in or security screen. Most Linux systems restart the virtual machine.
- button: Send
+ title: 'Send Ctrl+Alt+Del to {name}?'
+ message: The key combination goes straight to the guest operating system. On many systems, including most Linux distributions, this restarts the VM immediately. Unsaved work in the VM will be lost.
+ button: Send Ctrl+Alt+Del
softReboot:
- title: Soft reboot this virtual machine?
- message: The guest operating system is asked to restart. Unsaved work inside the virtual machine is lost.
- button: Soft Reboot
+ title: 'Soft reboot {name}?'
+ message: The guest operating system is asked to shut down and restart. Unsaved work in the VM will be lost.
+ button: Soft reboot
takeOver:
- label: Take Over
+ label: Take over
title: Take over this console?
confirm: The other VNC session connected to this virtual machine will be disconnected.
+ overlay:
+ connecting: 'Connecting to {name}…'
+ attempt: 'Attempt {retryTimes} of {maximumRetryTimes}.'
+ reconnectNow: Reconnect now
failure:
inUse: This console is in use
unavailable: Unable to connect
- dropped: Disconnected
+ dropped: Connection lost
error: Connection failed
failureDetail:
inUse: Another VNC session is connected to this virtual machine. Retry once it is released, or take over the console to disconnect the other session.
unavailable: The console is not available. Check that the virtual machine is running, then retry.
- dropped: The console connection was closed. Another VNC session may have opened this console.
+ dropped: The console stopped receiving data from the virtual machine. The VM itself may still be running, or another VNC session may have opened this console.
status:
- connecting: Connecting
+ connecting: Connecting…
connected: Connected
- connectedViewOnly: Connected (view only)
disconnected: Disconnected
+ notRunning: Not running
failed: Connection failed
reconnecting: 'Reconnecting {retryTimes} of {maximumRetryTimes}'
credentialsRequired: This console requires credentials that the dashboard cannot provide.