feat(xo6/xo-lite): add console clipboard to VM and host consoles (#10625)

This commit is contained in:
MlssFrncJrg
2026-10-28 11:30:53 +01:00
committed by GitHub
parent a953ca748d
commit 1609d2f825
17 changed files with 495 additions and 25 deletions

View File

@@ -18,6 +18,7 @@
- [Host/Network] Add the possibility to forget a PIF (PR [#10239](https://github.com/vatesfr/xen-orchestra/pull/10239))
- [Lite] Display dates and times as `YYYY-MM-DD HH:mm:ss` (PR [#10547](https://github.com/vatesfr/xen-orchestra/pull/10547))
- [Pool/Hosts] Add a side panel displaying the selected host's details, and a search bar to filter the hosts table (PR [#10580](https://github.com/vatesfr/xen-orchestra/pull/10580))
- [VM/Host] Add console clipboard management (PR [#10625](https://github.com/vatesfr/xen-orchestra/pull/10625))
## **0.26.0** (2026-10-01)

View File

@@ -12,11 +12,22 @@
</VtsStateHero>
<template v-else-if="host && hostConsole">
<VtsLayoutConsole>
<VtsRemoteConsole v-if="url" ref="console-element" :url :is-console-available="isConsoleAvailable" />
<VtsRemoteConsole
v-if="url"
ref="console-element"
:url
:is-console-available="isConsoleAvailable"
@clipboard="clipboardText = $event"
/>
<template #actions>
<VtsActionsConsole :send-ctrl-alt-del="sendCtrlAltDel" />
<VtsDivider type="stretch" />
<VtsClipboardConsole />
<VtsClipboardConsole
v-model="clipboardText"
:is-console-connected="isConsoleConnected"
:send-to-console="sendToConsole"
is-keyboard-layout-disabled
/>
</template>
</VtsLayoutConsole>
</template>
@@ -39,6 +50,7 @@ import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useRemoteConsole } from '@core/composables/remote-console.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -107,9 +119,9 @@ const isConsoleAvailable = computed(() =>
controlDomain.value !== undefined ? !isHostOperationPending(host.value!, STOP_OPERATIONS) : false
)
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
const { clipboardText, isConsoleConnected, sendCtrlAltDel, sendToConsole } = useRemoteConsole(
useTemplateRef('console-element')
)
</script>
<style lang="postcss" scoped>

View File

@@ -12,11 +12,21 @@
</VtsStateHero>
<template v-else-if="vm && vmConsole">
<VtsLayoutConsole>
<VtsRemoteConsole v-if="url" ref="console-element" :url :is-console-available="isConsoleAvailable" />
<VtsRemoteConsole
v-if="url"
ref="console-element"
:url
:is-console-available="isConsoleAvailable"
@clipboard="clipboardText = $event"
/>
<template #actions>
<VtsActionsConsole :send-ctrl-alt-del="sendCtrlAltDel" />
<VtsDivider type="stretch" />
<VtsClipboardConsole />
<VtsClipboardConsole
v-model="clipboardText"
:is-console-connected="isConsoleConnected"
:send-to-console="sendToConsole"
/>
</template>
</VtsLayoutConsole>
</template>
@@ -38,6 +48,7 @@ import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useRemoteConsole } from '@core/composables/remote-console.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -101,9 +112,9 @@ const isConsoleAvailable = computed(() =>
vm.value !== undefined ? !isVmOperationPending(vm.value, STOP_OPERATIONS) : false
)
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
const { clipboardText, isConsoleConnected, sendCtrlAltDel, sendToConsole } = useRemoteConsole(
useTemplateRef('console-element')
)
</script>
<style lang="postcss" scoped>

View File

@@ -1,29 +1,107 @@
<template>
<div class="vts-clipboard-console">
<VtsInputWrapper
:label="t('console-keyboard-layout')"
:message="isKeyboardLayoutDisabled ? t('console-keyboard-layout-not-applicable') : undefined"
wrap-message
>
<VtsSelect :id="keyboardLayoutSelectId" accent="brand" />
</VtsInputWrapper>
<UiCardTitle>{{ t('console-clipboard') }}</UiCardTitle>
<UiTextarea v-tooltip="t('coming-soon!')" accent="brand" disabled :model-value="modelValue" />
<UiTextarea v-model="clipboardText" accent="brand" />
<div class="buttons-container">
<UiButton v-tooltip="t('coming-soon!')" accent="brand" variant="primary" size="medium" disabled>
<UiButton
accent="brand"
variant="primary"
size="medium"
:busy="isTyping"
:disabled="!isConsoleConnected || clipboardText.length === 0"
@click="typeTextToConsole()"
>
{{ t('action:send') }}
</UiButton>
<UiButton v-tooltip="t('coming-soon!')" accent="brand" variant="secondary" size="medium" disabled>
<UiButton
v-tooltip="!isSupported ? t('copy-unavailable-http') : copied && t('copied')"
accent="brand"
variant="secondary"
size="medium"
:disabled="!isSupported"
@click="copy()"
>
{{ t('receive') }}
</UiButton>
</div>
<UiInfo v-if="skippedChars.length > 0" accent="warning" wrap>
{{ t('console-type-text-skipped', { chars: skippedChars.join(' ') }) }}
</UiInfo>
</div>
</template>
<script setup lang="ts">
import type { RemoteConsoleTypeText } from '@core/components/console/VtsRemoteConsole.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiTextarea from '@core/components/ui/text-area/UiTextarea.vue'
import { useCopyToClipboard } from '@core/composables/copy.composable.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { ref } from 'vue'
import { KEYBOARD_LAYOUTS } from '@core/keyboard-layouts/keyboard-layouts.ts'
import { useFormSelect } from '@core/packages/form-select'
import type { KeyboardLayoutId } from '@core/types/keyboard-layout.type.ts'
import { guessKeyboardLayoutId } from '@core/utils/keyboard-layout.util.ts'
import { useLocalStorage } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const modelValue = ref('')
const {
isConsoleConnected = false,
isKeyboardLayoutDisabled,
sendToConsole,
} = defineProps<{
isConsoleConnected?: boolean
isKeyboardLayoutDisabled?: boolean
sendToConsole?: RemoteConsoleTypeText
}>()
const clipboardText = defineModel<string>({ default: '' })
const { t } = useI18n()
const { copy, copied, isSupported } = useCopyToClipboard(() => clipboardText.value)
const keyboardLayoutId = useLocalStorage<KeyboardLayoutId>(
'console.keyboard-layout',
guessKeyboardLayoutId(navigator.language)
)
const { id: keyboardLayoutSelectId } = useFormSelect(KEYBOARD_LAYOUTS, {
model: keyboardLayoutId,
disabled: () => isKeyboardLayoutDisabled,
option: {
value: 'id',
},
})
const isTyping = ref(false)
const skippedChars = ref<string[]>([])
watch(clipboardText, () => {
skippedChars.value = []
})
async function typeTextToConsole() {
isTyping.value = true
const layoutId = isKeyboardLayoutDisabled ? undefined : keyboardLayoutId.value
try {
skippedChars.value = (await sendToConsole?.(clipboardText.value, layoutId)) ?? []
} finally {
isTyping.value = false
}
}
</script>
<style lang="postcss" scoped>

View File

@@ -8,6 +8,8 @@
<script lang="ts" setup>
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
import type { KeyboardLayoutId } from '@core/types/keyboard-layout.type.ts'
import { getKeyboardLayout, getKeysymKeyStrokes, typeText } from '@core/utils/keyboard-layout.util.ts'
import type NoVncClient from '@novnc/novnc/lib/rfb'
import _RFB from '@novnc/novnc/lib/rfb'
import { whenever } from '@vueuse/core'
@@ -16,11 +18,23 @@ import { fibonacci } from 'iterable-backoff'
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
export type RemoteConsoleTypeText = (text: string, layoutId?: KeyboardLayoutId) => Promise<string[]> | undefined
export type RemoteConsoleExposed = {
isConnected: boolean
sendCtrlAltDel: () => void
typeText: RemoteConsoleTypeText
}
const { url, isConsoleAvailable } = defineProps<{
url: URL
isConsoleAvailable: boolean
}>()
const emit = defineEmits<{
clipboard: [text: string]
}>()
const { t } = useI18n()
const uiStore = useUiStore()
@@ -59,12 +73,17 @@ function handleConnectionEvent() {
isReady.value = true
}
function handleClipboardEvent(event: CustomEvent<{ text: string }>) {
emit('clipboard', event.detail.text)
}
function clearVncClient() {
isReady.value = false
if (vncClient === undefined) {
return
}
vncClient.removeEventListener('clipboard', handleClipboardEvent)
vncClient.removeEventListener('disconnect', handleDisconnectionEvent)
vncClient.removeEventListener('connect', handleConnectionEvent)
vncClient.disconnect()
@@ -90,6 +109,7 @@ async function createVncConnection() {
vncClient.addEventListener('disconnect', handleDisconnectionEvent)
vncClient.addEventListener('connect', handleConnectionEvent)
vncClient.addEventListener('clipboard', handleClipboardEvent)
const canvas = consoleContainer.value?.querySelector('canvas') as HTMLCanvasElement | null
if (canvas !== null) {
// Todo: See with Clémence to specify the desired focus behavior
@@ -137,8 +157,28 @@ onBeforeUnmount(() => {
clearVncClient()
})
function typeTextInConsole(text: string, layoutId?: KeyboardLayoutId) {
const client = vncClient
if (client === undefined) {
return
}
// not exposed by noVNC and only true when the server announced the QEMU extended key event, which the host console does not
const isScancodeSupported = '_qemuExtKeyEventSupported' in client && client._qemuExtKeyEventSupported === true
return typeText(
(keysym, code, down) => client.sendKey(keysym, code, down),
text,
isScancodeSupported && layoutId !== undefined ? getKeyboardLayout(layoutId).getKeyStrokes : getKeysymKeyStrokes,
() => vncClient !== client
)
}
defineExpose({
isConnected: isReady,
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
typeText: typeTextInConsole,
})
</script>

View File

@@ -0,0 +1,14 @@
import type { RemoteConsoleExposed } from '@core/components/console/VtsRemoteConsole.vue'
import type { KeyboardLayoutId } from '@core/types/keyboard-layout.type.ts'
import { computed, type MaybeRefOrGetter, ref, toValue } from 'vue'
export function useRemoteConsole(remoteConsole: MaybeRefOrGetter<RemoteConsoleExposed | null | undefined>) {
const clipboardText = ref('')
const isConsoleConnected = computed(() => toValue(remoteConsole)?.isConnected ?? false)
const sendCtrlAltDel = () => toValue(remoteConsole)?.sendCtrlAltDel()
const sendToConsole = (text: string, layoutId?: KeyboardLayoutId) => toValue(remoteConsole)?.typeText(text, layoutId)
return { clipboardText, isConsoleConnected, sendCtrlAltDel, sendToConsole }
}

View File

@@ -0,0 +1,90 @@
import type { KeyStroke } from '@core/types/keyboard-layout.type.ts'
import { defineKeyboardLayout } from './define-keyboard-layout.ts'
// Letters whose physical key differs from QWERTY
const LETTER_CODES: Record<string, string> = { a: 'KeyQ', q: 'KeyA', z: 'KeyW', w: 'KeyZ', m: 'Semicolon' }
const DEAD_CIRCUMFLEX: KeyStroke = { code: 'BracketLeft' }
const DEAD_DIAERESIS: KeyStroke = { code: 'BracketLeft', shift: true }
const SYMBOLS: Record<string, KeyStroke[]> = {
' ': [{ code: 'Space' }],
'\n': [{ code: 'Enter' }],
'\t': [{ code: 'Tab' }],
'²': [{ code: 'Backquote' }],
'&': [{ code: 'Digit1' }],
é: [{ code: 'Digit2' }],
'"': [{ code: 'Digit3' }],
"'": [{ code: 'Digit4' }],
'(': [{ code: 'Digit5' }],
'-': [{ code: 'Digit6' }],
è: [{ code: 'Digit7' }],
_: [{ code: 'Digit8' }],
ç: [{ code: 'Digit9' }],
à: [{ code: 'Digit0' }],
')': [{ code: 'Minus' }],
'°': [{ code: 'Minus', shift: true }],
'=': [{ code: 'Equal' }],
'+': [{ code: 'Equal', shift: true }],
$: [{ code: 'BracketRight' }],
'£': [{ code: 'BracketRight', shift: true }],
'¤': [{ code: 'BracketRight', altGr: true }],
ù: [{ code: 'Quote' }],
'%': [{ code: 'Quote', shift: true }],
'*': [{ code: 'Backslash' }],
µ: [{ code: 'Backslash', shift: true }],
'<': [{ code: 'IntlBackslash' }],
'>': [{ code: 'IntlBackslash', shift: true }],
',': [{ code: 'KeyM' }],
'?': [{ code: 'KeyM', shift: true }],
';': [{ code: 'Comma' }],
'.': [{ code: 'Comma', shift: true }],
':': [{ code: 'Period' }],
'/': [{ code: 'Period', shift: true }],
'!': [{ code: 'Slash' }],
'§': [{ code: 'Slash', shift: true }],
// Dead keys on Windows: combine with a following vowel or "n" (e.g. "~a" gives "ã")
'~': [{ code: 'Digit2', altGr: true }],
'`': [{ code: 'Digit7', altGr: true }],
'#': [{ code: 'Digit3', altGr: true }],
'{': [{ code: 'Digit4', altGr: true }],
'[': [{ code: 'Digit5', altGr: true }],
'|': [{ code: 'Digit6', altGr: true }],
'\\': [{ code: 'Digit8', altGr: true }],
'@': [{ code: 'Digit0', altGr: true }],
']': [{ code: 'Minus', altGr: true }],
'}': [{ code: 'Equal', altGr: true }],
'€': [{ code: 'KeyE', altGr: true }],
'^': [DEAD_CIRCUMFLEX, { code: 'Space' }],
// Gives '"' instead of '¨' on Linux, as with a physical keyboard
'¨': [DEAD_DIAERESIS, { code: 'Space' }],
â: [DEAD_CIRCUMFLEX, { code: 'KeyQ' }],
ê: [DEAD_CIRCUMFLEX, { code: 'KeyE' }],
î: [DEAD_CIRCUMFLEX, { code: 'KeyI' }],
ô: [DEAD_CIRCUMFLEX, { code: 'KeyO' }],
û: [DEAD_CIRCUMFLEX, { code: 'KeyU' }],
Â: [DEAD_CIRCUMFLEX, { code: 'KeyQ', shift: true }],
Ê: [DEAD_CIRCUMFLEX, { code: 'KeyE', shift: true }],
Î: [DEAD_CIRCUMFLEX, { code: 'KeyI', shift: true }],
Ô: [DEAD_CIRCUMFLEX, { code: 'KeyO', shift: true }],
Û: [DEAD_CIRCUMFLEX, { code: 'KeyU', shift: true }],
ä: [DEAD_DIAERESIS, { code: 'KeyQ' }],
ë: [DEAD_DIAERESIS, { code: 'KeyE' }],
ï: [DEAD_DIAERESIS, { code: 'KeyI' }],
ö: [DEAD_DIAERESIS, { code: 'KeyO' }],
ü: [DEAD_DIAERESIS, { code: 'KeyU' }],
ÿ: [DEAD_DIAERESIS, { code: 'KeyY' }],
Ä: [DEAD_DIAERESIS, { code: 'KeyQ', shift: true }],
Ë: [DEAD_DIAERESIS, { code: 'KeyE', shift: true }],
Ï: [DEAD_DIAERESIS, { code: 'KeyI', shift: true }],
Ö: [DEAD_DIAERESIS, { code: 'KeyO', shift: true }],
Ü: [DEAD_DIAERESIS, { code: 'KeyU', shift: true }],
}
export const azertyFrLayout = defineKeyboardLayout({
id: 'fr-azerty',
label: 'Français (AZERTY)',
letterCodes: LETTER_CODES,
shiftedDigits: true,
symbols: SYMBOLS,
})

View File

@@ -0,0 +1,34 @@
import type { KeyboardLayout, KeyboardLayoutId, KeyStroke } from '@core/types/keyboard-layout.type.ts'
export function defineKeyboardLayout(config: {
id: KeyboardLayoutId
label: string
// Characters typed with a specific key sequence (symbols, accented letters, dead keys...)
symbols: Record<string, KeyStroke[]>
// Letters whose physical key differs from QWERTY
letterCodes?: Record<string, string>
// Whether digits are typed with Shift
shiftedDigits?: boolean
}): KeyboardLayout {
const { id, label, symbols, letterCodes = {}, shiftedDigits = false } = config
function getKeyStrokes(char: string): KeyStroke[] | undefined {
if (symbols[char] !== undefined) {
return symbols[char]
}
if (/^[a-zA-Z]$/.test(char)) {
const lowerChar = char.toLowerCase()
return [{ code: letterCodes[lowerChar] ?? `Key${lowerChar.toUpperCase()}`, shift: char !== lowerChar }]
}
if (/^[0-9]$/.test(char)) {
return [{ code: `Digit${char}`, shift: shiftedDigits }]
}
return undefined
}
return { id, label, getKeyStrokes }
}

View File

@@ -0,0 +1,5 @@
import type { KeyboardLayout } from '@core/types/keyboard-layout.type.ts'
import { azertyFrLayout } from './azerty-fr.layout.ts'
import { qwertyUsLayout } from './qwerty-us.layout.ts'
export const KEYBOARD_LAYOUTS: KeyboardLayout[] = [qwertyUsLayout, azertyFrLayout]

View File

@@ -0,0 +1,46 @@
import type { KeyStroke } from '@core/types/keyboard-layout.type.ts'
import { defineKeyboardLayout } from './define-keyboard-layout.ts'
const SYMBOLS: Record<string, KeyStroke[]> = {
' ': [{ code: 'Space' }],
'\n': [{ code: 'Enter' }],
'\t': [{ code: 'Tab' }],
'`': [{ code: 'Backquote' }],
'~': [{ code: 'Backquote', shift: true }],
'!': [{ code: 'Digit1', shift: true }],
'@': [{ code: 'Digit2', shift: true }],
'#': [{ code: 'Digit3', shift: true }],
$: [{ code: 'Digit4', shift: true }],
'%': [{ code: 'Digit5', shift: true }],
'^': [{ code: 'Digit6', shift: true }],
'&': [{ code: 'Digit7', shift: true }],
'*': [{ code: 'Digit8', shift: true }],
'(': [{ code: 'Digit9', shift: true }],
')': [{ code: 'Digit0', shift: true }],
'-': [{ code: 'Minus' }],
_: [{ code: 'Minus', shift: true }],
'=': [{ code: 'Equal' }],
'+': [{ code: 'Equal', shift: true }],
'[': [{ code: 'BracketLeft' }],
'{': [{ code: 'BracketLeft', shift: true }],
']': [{ code: 'BracketRight' }],
'}': [{ code: 'BracketRight', shift: true }],
'\\': [{ code: 'Backslash' }],
'|': [{ code: 'Backslash', shift: true }],
';': [{ code: 'Semicolon' }],
':': [{ code: 'Semicolon', shift: true }],
"'": [{ code: 'Quote' }],
'"': [{ code: 'Quote', shift: true }],
',': [{ code: 'Comma' }],
'<': [{ code: 'Comma', shift: true }],
'.': [{ code: 'Period' }],
'>': [{ code: 'Period', shift: true }],
'/': [{ code: 'Slash' }],
'?': [{ code: 'Slash', shift: true }],
}
export const qwertyUsLayout = defineKeyboardLayout({
id: 'en-us-qwerty',
label: 'English (US, QWERTY)',
symbols: SYMBOLS,
})

View File

@@ -330,7 +330,10 @@
"console": "Console",
"console-actions": "Console actions",
"console-clipboard": "Console clipboard",
"console-keyboard-layout": "Guest keyboard layout",
"console-keyboard-layout-not-applicable": "Not applicable: characters are sent as-is to this console",
"console-offline": "Console offline",
"console-type-text-skipped": "Not typed, missing from the selected keyboard layout: {chars}",
"console-unavailable": "Console unavailable",
"console-unavailable-reason": "The console is currently unavailable because the {type} is powered off.",
"container-name": "Container name",

View File

@@ -330,7 +330,10 @@
"console": "Console",
"console-actions": "Actions de la console",
"console-clipboard": "Presse-papiers de la console",
"console-keyboard-layout": "Disposition du clavier invité",
"console-keyboard-layout-not-applicable": "Non applicable : les caractères sont envoyés tels quels à cette console",
"console-offline": "Console hors ligne",
"console-type-text-skipped": "Non tapés, absents de la disposition choisie : {chars}",
"console-unavailable": "Console indisponible",
"console-unavailable-reason": "La console est actuellement indisponible car {type} est éteint.",
"container-name": "Nom du conteneur",

View File

@@ -0,0 +1,17 @@
// Physical key to press (KeyboardEvent.code), with the modifiers needed to produce the character
export type KeyStroke = {
code: string
// Character to send instead of the physical key, when the VNC server does not support scancodes
keysym?: number
shift?: boolean
altGr?: boolean
}
export type KeyboardLayoutId = 'en-us-qwerty' | 'fr-azerty'
export type KeyboardLayout = {
id: KeyboardLayoutId
label: string
// Returns the key strokes producing the character (several for a dead key), or undefined if the layout cannot type it
getKeyStrokes: (char: string) => KeyStroke[] | undefined
}

View File

@@ -0,0 +1,94 @@
import { KEYBOARD_LAYOUTS } from '@core/keyboard-layouts/keyboard-layouts.ts'
import { qwertyUsLayout } from '@core/keyboard-layouts/qwerty-us.layout.ts'
import type { KeyboardLayout, KeyboardLayoutId, KeyStroke } from '@core/types/keyboard-layout.type.ts'
import { getKeysym } from '@novnc/novnc/lib/input/util'
import { promiseTimeout } from '@vueuse/shared'
const XK_SHIFT_L = 0xffe1
const XK_ISO_LEVEL3_SHIFT = 0xfe03
// The guest drops keys sent in a burst (text cut after ~145 characters on a Fedora VM without delay)
const KEY_STROKE_DELAY_MS = 25
export type SendKey = (keysym: number, code: string, down?: boolean) => void
export function getKeyboardLayout(id: string): KeyboardLayout {
return KEYBOARD_LAYOUTS.find(layout => layout.id === id) ?? qwertyUsLayout
}
// The guest layout cannot be detected: assume it matches the browser language
export function guessKeyboardLayoutId(language: string): KeyboardLayoutId {
return /^fr(-fr)?$/i.test(language) ? 'fr-azerty' : 'en-us-qwerty'
}
const KEYSYM_KEYS: Record<string, string> = { '\n': 'Enter', '\t': 'Tab' }
/**
* Key strokes sending the character itself (keysym), for VNC servers without the QEMU extended key event
* (e.g. the host console): the guest receives the character, so its keyboard layout does not matter.
*
* The code is left empty so that noVNC sends the keysym, not a scancode.
*/
export function getKeysymKeyStrokes(char: string): KeyStroke[] | undefined {
// Same conversion as the keys typed on the physical keyboard
const keysym = getKeysym({ key: KEYSYM_KEYS[char] ?? char, code: '' })
return keysym ? [{ code: '', keysym }] : undefined
}
/**
* Types the text in the guest by sending, for each character, the key strokes returned by `getKeyStrokes`:
* - with a keyboard layout's `getKeyStrokes`, only the scancode is sent (keysym 0), so the guest translates it with its
* own layout, which must match. This requires the QEMU extended key event, without it noVNC sends nothing.
* - with `getKeysymKeyStrokes`, the character itself is sent, see its description.
*
* Typing stops as soon as `isStopped` returns true (e.g. the console was disconnected), checked before each key stroke
* so that no modifier is left pressed.
*
* @returns the characters that cannot be typed, which were skipped
*/
export async function typeText(
sendKey: SendKey,
text: string,
getKeyStrokes: KeyboardLayout['getKeyStrokes'],
isStopped: () => boolean
): Promise<string[]> {
const skippedChars = new Set<string>()
for (const char of text.replace(/\r\n/g, '\n')) {
const keyStrokes = getKeyStrokes(char)
if (keyStrokes === undefined) {
skippedChars.add(char)
continue
}
for (const { code, keysym = 0, shift, altGr } of keyStrokes) {
await promiseTimeout(KEY_STROKE_DELAY_MS)
if (isStopped()) {
return [...skippedChars]
}
if (shift) {
sendKey(XK_SHIFT_L, 'ShiftLeft', true)
}
if (altGr) {
sendKey(XK_ISO_LEVEL3_SHIFT, 'AltRight', true)
}
sendKey(keysym, code)
if (altGr) {
sendKey(XK_ISO_LEVEL3_SHIFT, 'AltRight', false)
}
if (shift) {
sendKey(XK_SHIFT_L, 'ShiftLeft', false)
}
}
}
return [...skippedChars]
}

View File

@@ -8,11 +8,21 @@
</div>
</VtsStateHero>
<VtsLayoutConsole v-else>
<VtsRemoteConsole ref="console-element" :url :is-console-available="isHostConsoleAvailable" />
<VtsRemoteConsole
ref="console-element"
:url
:is-console-available="isHostConsoleAvailable"
@clipboard="clipboardText = $event"
/>
<template #actions>
<VtsActionsConsole :send-ctrl-alt-del="sendCtrlAltDel" />
<VtsDivider type="stretch" />
<VtsClipboardConsole />
<VtsClipboardConsole
v-model="clipboardText"
:is-console-connected="isConsoleConnected"
:send-to-console="sendToConsole"
is-keyboard-layout-disabled
/>
</template>
</VtsLayoutConsole>
</template>
@@ -26,6 +36,7 @@ import VtsLayoutConsole from '@core/components/console/VtsLayoutConsole.vue'
import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { useRemoteConsole } from '@core/composables/remote-console.composable.ts'
import { HOST_ALLOWED_OPERATIONS } from '@vates/types'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -47,9 +58,9 @@ const isHostConsoleRunning = computed(() => props.host.power_state === 'Running'
const isHostConsoleAvailable = computed(() =>
props.host.controlDomain !== undefined ? !isHostOperationPending(props.host!, STOP_OPERATIONS) : false
)
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
const { clipboardText, isConsoleConnected, sendCtrlAltDel, sendToConsole } = useRemoteConsole(
useTemplateRef('console-element')
)
</script>
<style scoped lang="postcss">

View File

@@ -8,11 +8,20 @@
</div>
</VtsStateHero>
<VtsLayoutConsole v-else>
<VtsRemoteConsole ref="console-element" :url :is-console-available="isConsoleAvailable" />
<VtsRemoteConsole
ref="console-element"
:url
:is-console-available="isConsoleAvailable"
@clipboard="clipboardText = $event"
/>
<template #actions>
<VtsActionsConsole :send-ctrl-alt-del="sendCtrlAltDel" />
<VtsDivider type="stretch" />
<VtsClipboardConsole />
<VtsClipboardConsole
v-model="clipboardText"
:is-console-connected="isConsoleConnected"
:send-to-console="sendToConsole"
/>
</template>
</VtsLayoutConsole>
</template>
@@ -26,6 +35,7 @@ import VtsLayoutConsole from '@core/components/console/VtsLayoutConsole.vue'
import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { useRemoteConsole } from '@core/composables/remote-console.composable.ts'
import { VM_OPERATIONS } from '@vates/types'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -53,9 +63,9 @@ const isVmConsoleRunning = computed(() => props.vm.power_state === 'Running' &&
const isConsoleAvailable = computed(() =>
props.vm !== undefined ? !isVmOperationPending(props.vm, STOP_OPERATIONS) : false
)
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
const { clipboardText, isConsoleConnected, sendCtrlAltDel, sendToConsole } = useRemoteConsole(
useTemplateRef('console-element')
)
</script>
<style scoped lang="postcss">

View File

@@ -25,6 +25,7 @@
- [CLI] Add new command to `xo-server-db` cli: `disable-remote <id>`, disables a remote while xo-server is stopped (PR [#10587](https://github.com/vatesfr/xen-orchestra/pull/10587/))
- [REST API/SDN Controller] With the XAPI plugin, traffic rules take an optional OpenFlow `priority`, unique on their network, the highest applying first, in `add_traffic_rule`, `update_traffic_rule` and the JSON-RPC methods `sdnController.addRule` and `sdnController.addNetworkRule`; a rule that has one is deleted or updated only when it is given too, in `delete_traffic_rule`, the `oldRule` of `update_traffic_rule` and the JSON-RPC methods `sdnController.deleteRule` and `sdnController.deleteNetworkRule` (PR [#10525](https://github.com/vatesfr/xen-orchestra/pull/10525))
- [VM] Add migrate action on VMs (PR [#10615](https://github.com/vatesfr/xen-orchestra/pull/10615))
- [Host/VM] Add console clipboard management (PR [#10625](https://github.com/vatesfr/xen-orchestra/pull/10625))
### Bug fixes