Skip to content

Commit d243f78

Browse files
committed
Type text into VNC console respecting keyboard layout
General steps: 1. get text from clipboard 2. for each Unicode code point get the keystrokes required to trigger it in the given keyboard layout 3. send the keystrokes to the server using QEMUExtendedKeyEvent Mapping code point to keystrokes: 1. use ucs2keysym() method to convert Unicode code to X11 keysym code. Functionality is part of xorg-server-1.12.2/hw/xquartz/keysym2ucs.c that was ported to TypeScript. 2. map keysym code to mnemonic name using Qemu vnc_keysym.h 3. check if there is a mapping for that name in the keymap for the chosen keyboard layout. The keymaps originate from Qemu project where they are used to enforce a keyboard layout (-k switch) 4. resolved mapping consist of the scancode coressponding to physical key and a list of modifieres i.e. shift, altgr, numlock. Example: 1. assume single char ":" 2. Unicode code point is 0x03a 3. X11 keysym code is 0x03a (Latin-1 set has 1:1 mapping) 4. resolved mnemonic name is "colon" 5. in the keymap en-us name "colon" has following mapping: ['colon', 0x27, 'shift'] 6. based on this mapping we need to: a) press shift key b) press and release key with scancode 0x27 c) release shift key Reference-Url: https://github.com/qemu/qemu/blob/b69801dd6b1eb4d107f7c2f643adf0a4e3ec9124/ui/vnc_keysym.h Reference-Url: https://github.com/qemu/qemu/tree/b69801dd6b1eb4d107f7c2f643adf0a4e3ec9124/pc-bios/keymaps Reference-Url: https://www.x.org/releases/X11R7.7/src/xserver/xorg-server-1.12.2.tar.gz Signed-off-by: Radoslaw Szwajkowski <[email protected]>
1 parent 7ed136d commit d243f78

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+36905
-37
lines changed

locales/en/plugin__kubevirt-plugin.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"{{time}} seconds": "{{time}} seconds",
4444
"{{timestampPluralized}} ago": "{{timestampPluralized}} ago",
4545
"{{tolerations}} Toleration rules": "{{tolerations}} Toleration rules",
46+
"{{unsupportedCharCount}} characters are not supported by the keyboard layout mapping.": "{{unsupportedCharCount}} characters are not supported by the keyboard layout mapping.",
4647
"<0><0>Bridge binding</0>: Connects the VirtualMachine to the selected network, which is ideal for L2 devices.</0><1><0>SR-IOV binding</0>: Attaches a virtual function network device to the VirtualMachine for high performance.</1>": "<0><0>Bridge binding</0>: Connects the VirtualMachine to the selected network, which is ideal for L2 devices.</0><1><0>SR-IOV binding</0>: Attaches a virtual function network device to the VirtualMachine for high performance.</1>",
4748
"<0><0>WARNING:</0> this PVC is used as a base operating system image. New VMs will not be able to clone this image</0>": "<0><0>WARNING:</0> this PVC is used as a base operating system image. New VMs will not be able to clone this image</0>",
4849
"<0>Autounattend.xml</0><1>Autounattend will be picked up automatically during windows installation. it can be used with destructive actions such as disk formatting. Autounattend will only be used once during installation.</1><2><0>{t('Learn more')}</0></2>": "<0>Autounattend.xml</0><1>Autounattend will be picked up automatically during windows installation. it can be used with destructive actions such as disk formatting. Autounattend will only be used once during installation.</1><2><0>{t('Learn more')}</0></2>",
@@ -1372,6 +1373,7 @@
13721373
"Total vCPU is {{totalCPU}}": "Total vCPU is {{totalCPU}}",
13731374
"Tree view": "Tree view",
13741375
"Type": "Type",
1376+
"Type into console ({{selectedKeyboard}})": "Type into console ({{selectedKeyboard}})",
13751377
"Type to create folder": "Type to create folder",
13761378
"U series": "U series",
13771379
"UEFI": "UEFI",
@@ -1382,6 +1384,7 @@
13821384
"Unknown": "Unknown",
13831385
"Unknown failure": "Unknown failure",
13841386
"Unpause": "Unpause",
1387+
"Unsupported content in the clipboard": "Unsupported content in the clipboard",
13851388
"Up": "Up",
13861389
"Up to date": "Up to date",
13871390
"Update status": "Update status",

src/utils/components/Consoles/ConsoleStandAlone.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ import { useParams } from 'react-router-dom-v5-compat';
33

44
import { useVMIAndPodsForVM } from '@kubevirt-utils/resources/vm/hooks';
55

6+
import { ModalProvider, useModalValue } from '../ModalProvider/ModalProvider';
7+
68
import Consoles from './Consoles';
79

810
const ConsoleStandAlone: FC = () => {
911
const { name, ns } = useParams<{ name: string; ns: string }>();
1012
const { vmi } = useVMIAndPodsForVM(name, ns);
13+
const value = useModalValue();
1114

12-
return <Consoles consoleContainerClass="console-container-stand-alone" isStandAlone vmi={vmi} />;
15+
return (
16+
<ModalProvider value={value}>
17+
<Consoles consoleContainerClass="console-container-stand-alone" isStandAlone vmi={vmi} />;
18+
</ModalProvider>
19+
);
1320
};
1421

1522
export default ConsoleStandAlone;

src/utils/components/Consoles/Consoles.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const Consoles: FC<ConsolesProps> = ({ consoleContainerClass, isStandAlone, vmi
6565
</FlexItem>
6666
<FlexItem>
6767
<AccessConsoles
68+
isVnc={type === VNC_CONSOLE_TYPE}
6869
isWindowsVM={isWindowsVM}
6970
rfb={rfb}
7071
serialSocket={serialSocket}

src/utils/components/Consoles/components/AccessConsoles/AccessConsoles.tsx

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
import React, { FC, MouseEvent, useEffect, useState } from 'react';
1+
import React, { FC, MouseEvent, Ref, useEffect, useState } from 'react';
22

33
import DropdownToggle from '@kubevirt-utils/components/toggles/DropdownToggle';
44
import SelectToggle from '@kubevirt-utils/components/toggles/SelectToggle';
55
import { t } from '@kubevirt-utils/hooks/useKubevirtTranslation';
6+
import { keyMaps } from '@kubevirt-utils/keyboard/keymaps/keymaps';
7+
import { isKeyboardLayout, KeyboardLayout } from '@kubevirt-utils/keyboard/types';
68
import {
79
Button,
810
ButtonVariant,
11+
Divider,
912
Dropdown,
13+
DropdownGroup,
1014
DropdownItem,
1115
DropdownList,
16+
MenuToggle,
17+
MenuToggleAction,
18+
MenuToggleElement,
1219
Select,
1320
SelectOption,
1421
} from '@patternfly/react-core';
@@ -25,6 +32,7 @@ import './access-consoles.scss';
2532
const { connected } = ConsoleState;
2633

2734
export const AccessConsoles: FC<AccessConsolesProps> = ({
35+
isVnc,
2836
isWindowsVM,
2937
rfb,
3038
serialSocket,
@@ -34,6 +42,9 @@ export const AccessConsoles: FC<AccessConsolesProps> = ({
3442
const [isOpenSelectType, setIsOpenSelectType] = useState<boolean>(false);
3543
const [isOpenSendKey, setIsOpenSendKey] = useState<boolean>(false);
3644
const [status, setStatus] = useState<string>();
45+
const defaultKeyboard: KeyboardLayout = 'en-us';
46+
const [selectedKeyboard, setSelectedKeyboard] = useState<KeyboardLayout>(defaultKeyboard);
47+
const [isKeyboardSelectOpen, setIsKeyboardSelectOpen] = useState<boolean>(false);
3748

3849
useEffect(() => {
3950
const statusCallback = () => setStatus(connected);
@@ -60,7 +71,7 @@ export const AccessConsoles: FC<AccessConsolesProps> = ({
6071
const onInjectTextFromClipboard = (e: MouseEvent<HTMLButtonElement>) => {
6172
e.currentTarget.blur();
6273
e.preventDefault();
63-
rfb?.sendPasteCMD();
74+
rfb?.sendPasteCMD(selectedKeyboard);
6475
serialSocket?.onPaste();
6576
};
6677

@@ -69,18 +80,73 @@ export const AccessConsoles: FC<AccessConsolesProps> = ({
6980
serialSocket?.destroy();
7081
};
7182

83+
const typeInLabel = t('Type into console ({{selectedKeyboard}})', { selectedKeyboard });
7284
return (
7385
<>
74-
<Button
75-
icon={
76-
<>
77-
<PasteIcon /> {t('Paste to console')}
78-
</>
79-
}
80-
className="vnc-paste-button"
81-
onClick={onInjectTextFromClipboard}
82-
variant={ButtonVariant.link}
83-
/>
86+
{isVnc && (
87+
<Dropdown
88+
onSelect={(_event, value?: number | string) => {
89+
isKeyboardLayout(value) && setSelectedKeyboard(value);
90+
setIsKeyboardSelectOpen(false);
91+
}}
92+
toggle={(toggleRef: Ref<MenuToggleElement>) => (
93+
<MenuToggle
94+
splitButtonItems={[
95+
<MenuToggleAction
96+
aria-label={typeInLabel}
97+
key={typeInLabel}
98+
onClick={onInjectTextFromClipboard}
99+
>
100+
<PasteIcon />
101+
{typeInLabel}
102+
</MenuToggleAction>,
103+
]}
104+
className="vnc-paste-button"
105+
isExpanded={isKeyboardSelectOpen}
106+
onClick={() => setIsKeyboardSelectOpen(!isKeyboardSelectOpen)}
107+
ref={toggleRef}
108+
variant="secondary"
109+
>
110+
{selectedKeyboard}
111+
</MenuToggle>
112+
)}
113+
isOpen={isKeyboardSelectOpen}
114+
isScrollable
115+
onOpenChange={(isOpen) => setIsKeyboardSelectOpen(isOpen)}
116+
selected={selectedKeyboard}
117+
shouldFocusToggleOnSelect
118+
>
119+
<DropdownGroup>
120+
<DropdownItem description={defaultKeyboard} value={defaultKeyboard}>
121+
{keyMaps[defaultKeyboard].description}
122+
</DropdownItem>
123+
</DropdownGroup>
124+
<Divider component="li" />
125+
<DropdownList>
126+
{Object.entries(keyMaps)
127+
.filter(([value]) => value !== defaultKeyboard)
128+
.sort(([, a], [, b]) => a.description.localeCompare(b.description))
129+
.map(([value, def]) => (
130+
<DropdownItem description={value} key={value} value={value}>
131+
{def.description}
132+
</DropdownItem>
133+
))}
134+
</DropdownList>
135+
</Dropdown>
136+
)}
137+
{!isVnc && (
138+
<Button
139+
icon={
140+
<>
141+
<PasteIcon /> {t('Paste to console')}
142+
</>
143+
}
144+
className="vnc-paste-button"
145+
onClick={onInjectTextFromClipboard}
146+
variant={ButtonVariant.link}
147+
/>
148+
)}
149+
84150
<Select
85151
onSelect={(_, selection: string) => {
86152
setType(selection);

src/utils/components/Consoles/components/AccessConsoles/utils/accessConsoles.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '../../utils/ConsoleConsts';
1212

1313
export type AccessConsolesProps = {
14+
isVnc: boolean;
1415
isWindowsVM: boolean;
1516
rfb?: RFBCreate;
1617
serialSocket?: WSFactoryExtends;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { FC } from 'react';
2+
3+
import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation';
4+
import {
5+
Button,
6+
List,
7+
ListItem,
8+
Modal,
9+
ModalBody,
10+
ModalFooter,
11+
ModalHeader,
12+
} from '@patternfly/react-core';
13+
14+
type UnsupportedCharModal = {
15+
isOpen: boolean;
16+
onClose: () => void;
17+
unsupportedChars: string[];
18+
};
19+
20+
const UnsupportedCharModal: FC<UnsupportedCharModal> = ({ isOpen, onClose, unsupportedChars }) => {
21+
const { t } = useKubevirtTranslation();
22+
const maxChars = 10;
23+
const unsupportedCharCount = unsupportedChars.length;
24+
return (
25+
<Modal
26+
className="confirm-multiple-vm-actions-modal"
27+
isOpen={isOpen}
28+
onClose={onClose}
29+
position="top"
30+
variant={'small'}
31+
>
32+
<ModalHeader title={t('Unsupported content in the clipboard')} />
33+
<ModalBody>
34+
{t(
35+
'{{unsupportedCharCount}} characters are not supported by the keyboard layout mapping.',
36+
{
37+
unsupportedCharCount,
38+
},
39+
)}
40+
<List>
41+
{unsupportedChars.splice(0, maxChars).map((char) => (
42+
<ListItem key={char}>{`'${char}' (0x${char.codePointAt(0).toString(16)})`}</ListItem>
43+
))}
44+
{unsupportedCharCount > maxChars && <ListItem>...</ListItem>}
45+
</List>
46+
</ModalBody>
47+
<ModalFooter>
48+
<Button key="cancel" onClick={onClose} variant="link">
49+
{t('Cancel')}
50+
</Button>
51+
</ModalFooter>
52+
</Modal>
53+
);
54+
};
55+
56+
export default UnsupportedCharModal;

src/utils/components/Consoles/components/vnc-console/VncConsole.tsx

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import cn from 'classnames';
33

4+
import { useModal } from '@kubevirt-utils/components/ModalProvider/ModalProvider';
45
import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation';
6+
import { resolveCharMapping } from '@kubevirt-utils/keyboard/keyboard';
7+
import { keyMaps } from '@kubevirt-utils/keyboard/keymaps/keymaps';
8+
import {
9+
CharMappingWithModifiers,
10+
KeyboardLayout,
11+
KeyMapDef,
12+
} from '@kubevirt-utils/keyboard/types';
513
import { kubevirtConsole } from '@kubevirt-utils/utils/utils';
614
import KeyTable from '@novnc/novnc/lib/input/keysym';
715
import RFBCreate from '@novnc/novnc/lib/rfb';
@@ -13,14 +21,8 @@ import { isConnectionEncrypted, sleep } from '../../utils/utils';
1321
import { ConsoleState, WS, WSS } from '../utils/ConsoleConsts';
1422
import useCopyPasteConsole from '../utils/hooks/useCopyPasteConsole';
1523

16-
import {
17-
HORIZONTAL_TAB,
18-
isShiftKeyRequired,
19-
LATIN_1_FIRST_CHAR,
20-
LATIN_1_LAST_CHAR,
21-
LINE_FEED,
22-
} from './utils/util';
2324
import { VncConsoleProps } from './utils/VncConsoleTypes';
25+
import UnsupportedCharModal from './UnsupportedCharModal';
2426
import VncConnect from './VncConnect';
2527

2628
import './vnc-console.scss';
@@ -41,6 +43,7 @@ export const VncConsole: FC<VncConsoleProps> = ({
4143
const [activeTabKey, setActiveTabKey] = useState<number | string>(0);
4244
const pasteText = useCopyPasteConsole();
4345
const staticRenderLocationRef = useRef(null);
46+
const { createModal } = useModal();
4447
const StaticRenderLocation = useMemo(
4548
() => (
4649
<div
@@ -88,29 +91,58 @@ export const VncConsole: FC<VncConsoleProps> = ({
8891
this.sendKey(KeyTable.XK_Alt_L, 'AltLeft', false);
8992
this.sendKey(KeyTable.XK_Control_L, 'ControlLeft', false);
9093
};
91-
rfbInstnce.sendPasteCMD = async function sendPasteCMD() {
94+
rfbInstnce.sendPasteCMD = async function sendPasteCMD(selectedKeyboard: KeyboardLayout) {
9295
if (this._rfbConnectionState !== connected || this._viewOnly) {
9396
return;
9497
}
9598
const clipboardText = await navigator?.clipboard?.readText?.();
9699
const text = clipboardText || pasteText.current;
97-
for (const codePoint of text) {
98-
const codePointIndex = codePoint.codePointAt(0);
99-
if (codePointIndex === LINE_FEED) {
100-
this.sendKey(KeyTable.XK_Return);
101-
} else if (codePointIndex === HORIZONTAL_TAB) {
102-
this.sendKey(KeyTable.XK_Tab);
103-
} else if (codePointIndex >= LATIN_1_FIRST_CHAR && codePointIndex <= LATIN_1_LAST_CHAR) {
104-
// qemu maintains virtual keyboard state (caps lock, shift, etc)
105-
// keysyms are checked against that state and lower case version will be picked
106-
// if there is no shift/caps lock turn on
107-
const shiftRequired = isShiftKeyRequired(codePoint);
108-
shiftRequired && this.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', true);
109-
// long text is getting truncated without a delay
100+
const keyMap: KeyMapDef = keyMaps[selectedKeyboard];
101+
const mappedChars: CharMappingWithModifiers[] = [...text].map((codePoint) =>
102+
resolveCharMapping(codePoint, keyMap.map),
103+
);
104+
105+
const unsupportedChars = mappedChars.filter(({ mapping }) => mapping.scanCode === 0);
106+
if (unsupportedChars.length) {
107+
createModal((props) => (
108+
<UnsupportedCharModal
109+
{...props}
110+
unsupportedChars={Array.from(
111+
new Set(unsupportedChars.map(({ mapping }) => mapping.char ?? '<unknown>')),
112+
)}
113+
/>
114+
));
115+
return;
116+
}
117+
118+
for (const toType of mappedChars) {
119+
const { keysym, scanCode } = toType.mapping;
120+
121+
// qemu maintains virtual keyboard state (caps lock, shift, etc)
122+
// keysyms are checked against that state and lower case version will be picked
123+
// if there is no shift/caps lock turn on
124+
for (const modifier of toType.modifiers) {
125+
RFBCreate.messages.QEMUExtendedKeyEvent(
126+
this._sock,
127+
modifier.keysym,
128+
true,
129+
modifier.scanCode,
130+
);
131+
await sleep(50);
132+
}
133+
134+
RFBCreate.messages.QEMUExtendedKeyEvent(this._sock, keysym, true, scanCode);
135+
// long text is getting truncated without a delay
136+
await sleep(50);
137+
138+
for (const modifier of toType.modifiers) {
139+
RFBCreate.messages.QEMUExtendedKeyEvent(
140+
this._sock,
141+
modifier.keysym,
142+
false,
143+
modifier.scanCode,
144+
);
110145
await sleep(50);
111-
// Latin-1 set that maps directly to keysym
112-
this.sendKey(codePointIndex);
113-
shiftRequired && this.sendKey(KeyTable.XK_Shift_L, 'ShiftLeft', false);
114146
}
115147
}
116148
};
@@ -126,6 +158,7 @@ export const VncConsole: FC<VncConsoleProps> = ({
126158
scaleViewport,
127159
onConnect,
128160
pasteText,
161+
createModal,
129162
]);
130163

131164
useEffect(() => {

0 commit comments

Comments
 (0)