mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(xo6/xo-lite): add console clipboard to VM and host consoles (#10625)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
17
@xen-orchestra/web-core/lib/types/keyboard-layout.type.ts
Normal file
17
@xen-orchestra/web-core/lib/types/keyboard-layout.type.ts
Normal 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
|
||||
}
|
||||
94
@xen-orchestra/web-core/lib/utils/keyboard-layout.util.ts
Normal file
94
@xen-orchestra/web-core/lib/utils/keyboard-layout.util.ts
Normal 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]
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user