diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index d148ea10..ed397787 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -48,6 +48,8 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import MsdDialog from '@/components/MsdDialog.vue' import VideoDisplayControls from '@/components/VideoDisplayControls.vue' +import KeyboardShortcutsPopover from '@/components/KeyboardShortcutsPopover.vue' +import type { KeyboardShortcut } from '@/lib/keyboardShortcuts' import type { ConsoleLayout } from '@/composables/useConsoleLayout' const { t, locale } = useI18n() @@ -73,6 +75,9 @@ const props = defineProps<{ fullscreen?: boolean statsOpen?: boolean terminalOpen?: boolean + shortcutsReady?: boolean + shortcutInputBusy?: boolean + shortcutBusy?: boolean }>() const isSidebarLayout = computed(() => props.layout === 'sidebar') const isFloatingLayout = computed(() => props.layout === 'floating') @@ -106,6 +111,8 @@ const emit = defineEmits<{ (e: 'reset'): void (e: 'wol', macAddress: string): void (e: 'openTerminal'): void + (e: 'executeShortcut', shortcut: KeyboardShortcut): void + (e: 'cancelShortcut'): void }>() const pasteOpen = ref(false) @@ -114,6 +121,7 @@ const videoPopoverOpen = ref(false) const hidPopoverOpen = ref(false) const audioPopoverOpen = ref(false) const msdDialogOpen = ref(false) +const shortcutsOpen = ref(false) const mobileAtxOpen = ref(false) const mobilePasteOpen = ref(false) @@ -259,6 +267,7 @@ watch([() => props.layout, floatingCollapsed, minimalDisplayControls], () => { hidPopoverOpen.value = false audioPopoverOpen.value = false msdDialogOpen.value = false + shortcutsOpen.value = false mobileAtxOpen.value = false mobilePasteOpen.value = false void observeLayout() @@ -521,6 +530,16 @@ const hasRightOverflow = computed(() => { @toggle-virtual-keyboard="emit('toggleVirtualKeyboard')" /> + +
diff --git a/web/src/components/KeyboardShortcutsPopover.vue b/web/src/components/KeyboardShortcutsPopover.vue new file mode 100644 index 00000000..7ceeef65 --- /dev/null +++ b/web/src/components/KeyboardShortcutsPopover.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/web/src/components/VirtualKeyboard.vue b/web/src/components/VirtualKeyboard.vue index 3bd97080..47067f26 100644 --- a/web/src/components/VirtualKeyboard.vue +++ b/web/src/components/VirtualKeyboard.vue @@ -12,7 +12,6 @@ import { consumerKeys, latchingKeys, modifiers, - updateModifierMaskForKey, type KeyName, type ConsumerKeyName, } from '@/lib/keyboardMappings' @@ -29,6 +28,7 @@ const props = defineProps<{ capsLock?: boolean pressedKeys?: CanonicalKey[] consumerEnabled?: boolean + disabled?: boolean }>() const emit = defineEmits<{ @@ -88,7 +88,6 @@ const getBottomRow = () => const keyboardLayout = { main: { default: [ - 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', 'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp', 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown', @@ -97,7 +96,6 @@ const keyboardLayout = { 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight', ], shift: [ - 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', '(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp', 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown', @@ -142,10 +140,6 @@ const keyDisplayMap = computed>(() => { : selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta' return { - CtrlAltDelete: 'Ctrl+Alt+Del', - AltMetaEscape: 'Alt+Meta+Esc', - CtrlAltBackspace: 'Ctrl+Alt+Bksp', - ControlLeft: 'Ctrl', ControlRight: 'Ctrl', ShiftLeft: 'Shift', @@ -258,6 +252,7 @@ const keyDisplayMap = computed>(() => { }) async function onMediaKeyPress(key: string) { + if (props.disabled) return if (key in consumerKeys) { const usage = consumerKeys[key as ConsumerKeyName] try { @@ -293,26 +288,7 @@ function updateKeyboardLayout() { } async function onKeyDown(key: string) { - if (key === 'CtrlAltDelete') { - await executeMacro([ - { keys: ['Delete'], modifiers: ['ControlLeft', 'AltLeft'] }, - ]) - return - } - - if (key === 'AltMetaEscape') { - await executeMacro([ - { keys: ['Escape'], modifiers: ['AltLeft', 'MetaLeft'] }, - ]) - return - } - - if (key === 'CtrlAltBackspace') { - await executeMacro([ - { keys: ['Backspace'], modifiers: ['ControlLeft', 'AltLeft'] }, - ]) - return - } + if (props.disabled) return const cleanKey = key.replace(/[()]/g, '') @@ -377,54 +353,9 @@ async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: } } -interface MacroStep { - keys: string[] - modifiers: string[] -} - -async function executeMacro(steps: MacroStep[]) { - let macroModifierMask = pressedModifiers.value & 0xff - - for (const step of steps) { - for (const mod of step.modifiers) { - if (mod in keys) { - const modHid = keys[mod as KeyName] - macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true) - await sendKeyPress(modHid, true, macroModifierMask) - } - } - - for (const key of step.keys) { - if (key in keys) { - await sendKeyPress(keys[key as KeyName], true, macroModifierMask) - } - } - - await new Promise(resolve => setTimeout(resolve, 50)) - - for (const key of step.keys) { - if (key in keys) { - await sendKeyPress(keys[key as KeyName], false, macroModifierMask) - } - } - - for (const mod of step.modifiers) { - if (mod in keys) { - const modHid = keys[mod as KeyName] - macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false) - await sendKeyPress(modHid, false, macroModifierMask) - } - } - } -} - function updateKeyboardButtonTheme() { const downKeys = keyNamesForDownKeys.value.join(' ') const buttonTheme = [ - { - class: 'combination-key', - buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', - }, { class: 'down-key', buttons: downKeys, @@ -460,12 +391,6 @@ function initKeyboards() { theme: 'hg-theme-default hg-layout-default vkb-keyboard', onKeyPress: onKeyDown, onKeyReleased: onKeyUp, - buttonTheme: [ - { - class: 'combination-key', - buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace', - }, - ], disableButtonHold: true, preventMouseDownDefault: true, preventMouseUpDefault: true, @@ -559,7 +484,7 @@ watch(() => props.attached, (value) => { onMounted(() => { const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null - if (savedOs && ['windows', 'mac', 'android'].includes(savedOs)) { + if (savedOs && ['windows', 'linux', 'mac', 'android'].includes(savedOs)) { selectedOs.value = savedOs } @@ -617,7 +542,7 @@ onUnmounted(() => {
@@ -702,15 +627,6 @@ onUnmounted(() => { white-space: nowrap; } -/* Combination/macro keys */ -.vkb .simple-keyboard .hg-button.combination-key { - font-size: 10px; - height: 28px; - min-width: auto !important; - max-width: fit-content !important; - flex-grow: 0 !important; - padding: 0 8px; -} /* Pressed keys */ .vkb .simple-keyboard .hg-button.down-key { @@ -735,15 +651,8 @@ onUnmounted(() => { margin-bottom: 0; } -/* First row (macros) - left aligned */ +/* Function row spacing */ .kb-main-container .hg-row:first-child { - justify-content: flex-start !important; - margin-bottom: 8px; - gap: 4px; -} - -/* Second row (function keys) spacing */ -.kb-main-container .hg-row:nth-child(2) { margin-bottom: 8px; } @@ -1119,11 +1028,6 @@ html.dark .hg-theme-default .hg-button.down-key, min-width: 0; } - .vkb .simple-keyboard .hg-button.combination-key { - font-size: 9px; - height: 24px; - padding: 0 6px; - } .vkb .simple-keyboard .hg-button[data-skbtn="Backspace"], .vkb .simple-keyboard .hg-button[data-skbtn="Tab"], @@ -1182,11 +1086,6 @@ html.dark .hg-theme-default .hg-button.down-key, border-radius: 4px; } - .vkb .simple-keyboard .hg-button.combination-key { - font-size: 8px; - height: 22px; - padding: 0 4px; - } .vkb .simple-keyboard .hg-button[data-skbtn="Insert"], .vkb .simple-keyboard .hg-button[data-skbtn="Delete"], @@ -1234,10 +1133,6 @@ html.dark .hg-theme-default .hg-button.down-key, font-size: 12px; } -.vkb--floating :deep(.simple-keyboard .hg-button.combination-key) { - height: 26px; - font-size: 10px; -} /* Animation */ .keyboard-fade-enter-active, diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index e7ba1c84..7ac2cef5 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -109,6 +109,36 @@ export default { selectResolution: 'Select resolution...', selectFps: 'Select FPS...', }, + shortcuts: { + close: "Close shortcuts", + unavailable: "HID is disconnected", + releaseKeys: "Release held keys first", + stopped: "Shortcut stopped", + failed: "Shortcut interrupted. Check the HID connection.", + sysrqHelp: "SysRq is PrintScreen. SysRq must be enabled on the target Linux system. Open kernel documentation", + actions: { + 'ctrl-alt-delete': "Ctrl+Alt+Del", + 'task-manager': "Task Manager", + 'switch-window': "Switch windows", + 'close-window': "Close window", + 'lock-windows': "Lock Windows", + 'run': "Run", + 'tty1': "Switch to virtual terminal 1", + 'tty2': "Switch to virtual terminal 2", + 'restart-x': "Restart the X server", + 'force-quit': "Force Quit Applications", + 'sysrq-h': "Show help", + 'sysrq-f': "Invoke the OOM killer", + 'sysrq-m': "Show memory information", + 'sysrq-t': "Show tasks", + 'sysrq-r': "Restore keyboard mode", + 'sysrq-e': "Terminate all processes except init", + 'sysrq-i': "Kill all processes except init", + 'sysrq-s': "Sync disks", + 'sysrq-u': "Remount filesystems read-only", + 'sysrq-b': "Reboot immediately without syncing disks", + }, + }, common: { loading: 'Loading...', save: 'Save', @@ -236,6 +266,7 @@ export default { power: 'Power', keyboard: 'Virtual Keyboard', keyboardTip: 'Show virtual keyboard', + shortcuts: 'Shortcuts', webTerminal: 'Web Terminal', stats: 'Stats', statsTip: 'View connection statistics', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index fa65c37c..91b88bf6 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -109,6 +109,36 @@ export default { selectResolution: '选择分辨率...', selectFps: '选择帧率...', }, + shortcuts: { + close: "关闭快捷键", + unavailable: "HID 未连接", + releaseKeys: "请先释放已按下的键", + stopped: "已停止快捷键发送", + failed: "快捷键发送中断,请检查 HID 连接", + sysrqHelp: "SysRq 对应 PrintScreen;被控 Linux 需已启用。查看内核文档", + actions: { + 'ctrl-alt-delete': "Ctrl+Alt+Del", + 'task-manager': "任务管理器", + 'switch-window': "切换窗口", + 'close-window': "关闭窗口", + 'lock-windows': "锁定 Windows", + 'run': "运行", + 'tty1': "切换到虚拟终端 1", + 'tty2': "切换到虚拟终端 2", + 'restart-x': "重启 X 服务", + 'force-quit': "强制退出应用", + 'sysrq-h': "显示帮助", + 'sysrq-f': "调用 OOM killer", + 'sysrq-m': "显示内存信息", + 'sysrq-t': "显示任务信息", + 'sysrq-r': "恢复键盘模式", + 'sysrq-e': "终止所有进程(除 init)", + 'sysrq-i': "强制终止所有进程(除 init)", + 'sysrq-s': "同步磁盘", + 'sysrq-u': "重新挂载为只读", + 'sysrq-b': "立即重启,不先同步磁盘", + }, + }, common: { loading: '加载中...', save: '保存', @@ -236,6 +266,7 @@ export default { power: '电源', keyboard: '虚拟键盘', keyboardTip: '显示虚拟键盘', + shortcuts: '快捷键', webTerminal: '网页终端', stats: '连接统计', statsTip: '查看连接状态', diff --git a/web/src/lib/keyboardLayouts.ts b/web/src/lib/keyboardLayouts.ts index 4796bdde..8ea5472a 100644 --- a/web/src/lib/keyboardLayouts.ts +++ b/web/src/lib/keyboardLayouts.ts @@ -1,8 +1,9 @@ -export type KeyboardOsType = 'windows' | 'mac' | 'android' +export type KeyboardOsType = 'windows' | 'linux' | 'mac' | 'android' export const osBottomRows: Record = { windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'], + linux: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'], mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'], android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'], } diff --git a/web/src/lib/keyboardShortcuts.ts b/web/src/lib/keyboardShortcuts.ts new file mode 100644 index 00000000..8158944e --- /dev/null +++ b/web/src/lib/keyboardShortcuts.ts @@ -0,0 +1,108 @@ +import { CanonicalKey as Key } from '@/types/generated' +import { updateModifierMaskForKey } from '@/lib/keyboardMappings' + +export interface KeyboardShortcut { + id: string + label: string + description: string + keys: Key[] + destructive?: boolean +} + +function combination(id: string, label: string, keys: Key[]): KeyboardShortcut { + return { id, label, description: id, keys } +} + +export const keyboardShortcuts: KeyboardShortcut[] = [ + combination('ctrl-alt-delete', 'Ctrl+Alt+Del', [Key.ControlLeft, Key.AltLeft, Key.Delete]), + combination('task-manager', 'Ctrl+Shift+Esc', [Key.ControlLeft, Key.ShiftLeft, Key.Escape]), + combination('switch-window', 'Alt+Tab', [Key.AltLeft, Key.Tab]), + combination('close-window', 'Alt+F4', [Key.AltLeft, Key.F4]), + combination('lock-windows', 'Win+L', [Key.MetaLeft, Key.KeyL]), + combination('run', 'Win+R', [Key.MetaLeft, Key.KeyR]), + combination('tty1', 'Ctrl+Alt+F1', [Key.ControlLeft, Key.AltLeft, Key.F1]), + combination('tty2', 'Ctrl+Alt+F2', [Key.ControlLeft, Key.AltLeft, Key.F2]), + combination('restart-x', 'Ctrl+Alt+Bksp', [Key.ControlLeft, Key.AltLeft, Key.Backspace]), + combination('force-quit', 'Cmd+Opt+Esc', [Key.MetaLeft, Key.AltLeft, Key.Escape]), +] + +function sysrq(letter: string, key: Key, destructive = false): KeyboardShortcut { + return { + id: `sysrq-${letter.toLowerCase()}`, label: letter, + description: `sysrq-${letter.toLowerCase()}`, destructive, + keys: [Key.AltLeft, Key.PrintScreen, key], + } +} + +export const sysrqDiagnostics = [ + sysrq('H', Key.KeyH), sysrq('F', Key.KeyF, true), + sysrq('M', Key.KeyM), sysrq('T', Key.KeyT), +] +export const sysrqRecovery = [ + sysrq('R', Key.KeyR), sysrq('E', Key.KeyE, true), sysrq('I', Key.KeyI, true), + sysrq('S', Key.KeyS), sysrq('U', Key.KeyU), sysrq('B', Key.KeyB, true), +] + +export type ShortcutSender = (type: 'down' | 'up', key: Key, modifiers: number) => Promise + +function checkCancelled(signal?: AbortSignal) { + if (signal?.aborted) throw signal.reason ?? new DOMException('Cancelled', 'AbortError') +} + +function delay(ms: number, signal?: AbortSignal): Promise { + checkCancelled(signal) + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer) + reject(signal?.reason ?? new DOMException('Cancelled', 'AbortError')) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', abort) + resolve() + }, ms) + signal?.addEventListener('abort', abort, { once: true }) + }) +} + +/** Send on one ordered transport; always attempt to release every attempted key. */ +export async function executeKeyboardShortcut( + shortcut: KeyboardShortcut, + send: ShortcutSender, + options: { + signal?: AbortSignal + wait?: typeof delay + } = {}, +) { + const wait = options.wait ?? delay + checkCancelled(options.signal) + const held: Key[] = [] + let mask = 0 + let failure: unknown + try { + for (const key of shortcut.keys) { + checkCancelled(options.signal) + // A failed send may still have reached the device, so track it first. + held.push(key) + mask = updateModifierMaskForKey(mask, key, true) + await send('down', key, mask) + await wait(40, options.signal) + } + await wait(100, options.signal) + } catch (error) { + failure = error + } finally { + // Cleanup must not inherit cancellation, and one failed release must not + // prevent the remaining modifiers from being released. + for (const key of held.reverse()) { + mask = updateModifierMaskForKey(mask, key, false) + try { + await send('up', key, mask) + await wait(20) + } catch (error) { + failure ??= error + } + } + } + if (failure) throw failure + checkCancelled(options.signal) +} diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index b6b82001..63e6f88d 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -20,6 +20,7 @@ import { streamApi, hidApi, atxApi, atxConfigApi, authApi, uacApi } from '@/api' import { CanonicalKey, HidBackend } from '@/types/generated' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' +import { executeKeyboardShortcut, type KeyboardShortcut } from '@/lib/keyboardShortcuts' import { toast } from 'vue-sonner' import { cn, generateUUID } from '@/lib/utils' import { formatFpsValue } from '@/lib/fps' @@ -167,6 +168,8 @@ const keyboardLed = computed(() => ({ })) const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false) const keyboardLockPending = ref(false) +const shortcutBusy = ref(false) +let shortcutController: AbortController | null = null const activeModifierMask = ref(0) const mousePosition = ref({ x: 0, y: 0 }) const lastMousePosition = ref({ x: 0, y: 0 }) @@ -2076,9 +2079,53 @@ function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: nu transmitKeyboardEvent(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`)) } +function cancelShortcut() { + shortcutController?.abort() +} + +async function executeShortcut(shortcut: KeyboardShortcut) { + if (shortcutBusy.value || !isConsoleActive.value || hidStatus.value !== 'connected') return + if (pressedKeys.value.length || activeModifierMask.value || keyboardLockPending.value) { + toast.info(t('shortcuts.releaseKeys')) + return + } + const controller = new AbortController() + shortcutController = controller + shortcutBusy.value = true + // Unlike the low-latency WebRTC channel (limited retransmissions), WebSocket + // delivers the whole chord reliably and in order, independent of video mode. + try { + await executeKeyboardShortcut(shortcut, async (type, key, modifier) => { + if (type === 'down' && hidStatus.value !== 'connected') throw new Error('HID disconnected') + const event: HidKeyboardEvent = { type: type === 'down' ? 'keydown' : 'keyup', key, modifier } + await hidWs.sendKeyboard(event) + }, { + signal: controller.signal, + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + if (isConsoleActive.value) toast.info(t('shortcuts.stopped')) + } else { + console.error('[Shortcuts] Send failed:', error) + toast.error(t('shortcuts.failed')) + } + } finally { + shortcutController = null + shortcutBusy.value = false + } +} + +watch(hidStatus, status => { + if (status !== 'connected') shortcutController?.abort(new Error('HID disconnected')) +}, { flush: 'sync' }) +watch([hidWs.connected, hidWs.networkError, hidWs.hidUnavailable], ([connected, error, unavailable]) => { + if (!connected || error || unavailable) shortcutController?.abort(new Error('HID WebSocket disconnected')) +}, { flush: 'sync' }) +watch([videoMode, consoleLayout], cancelShortcut, { flush: 'sync' }) + async function toggleKeyboardLock(key: CanonicalKey.CapsLock | CanonicalKey.NumLock | CanonicalKey.ScrollLock) { if (!keyboardLedEnabled.value || hidStatus.value !== 'connected' - || keyboardLockPending.value || pressedKeys.value.includes(key)) return + || shortcutBusy.value || keyboardLockPending.value || pressedKeys.value.includes(key)) return keyboardLockPending.value = true try { @@ -2127,6 +2174,8 @@ function shouldBlockKey(e: KeyboardEvent): boolean { } function handleKeyDown(e: KeyboardEvent) { + if (shortcutBusy.value || document.querySelector('[data-keyboard-shortcuts]') + || (e.target instanceof Element && e.target.closest('[data-shortcut-surface]'))) return const container = videoContainerRef.value if (!container) return @@ -2156,22 +2205,18 @@ function handleKeyDown(e: KeyboardEvent) { } function handleKeyUp(e: KeyboardEvent) { - const container = videoContainerRef.value - if (!container) return - - if (!isFullscreen.value && !container.contains(document.activeElement)) return - - if (shouldBlockKey(e)) { + if (shortcutBusy.value) return + const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key) + // A local Tab/Enter can move focus before keyup. Only release keys whose + // keydown was actually forwarded; do release them after focus moves away. + if (canonicalKey === undefined || !pressedKeys.value.includes(canonicalKey)) return + const localShortcut = document.querySelector('[data-keyboard-shortcuts]') + || (e.target instanceof Element && e.target.closest('[data-shortcut-surface]')) + if (!localShortcut && shouldBlockKey(e)) { e.preventDefault() e.stopPropagation() } - const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key) - if (canonicalKey === undefined) { - console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`) - return - } - pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey) const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false) @@ -2837,6 +2882,7 @@ function handleFullscreenChange() { } function handleBlur() { + cancelShortcut() resetTouchInput() pressedKeys.value = [] activeModifierMask.value = 0 @@ -3266,6 +3312,11 @@ onUnmounted(() => { :fullscreen="isFullscreen" :stats-open="statsSheetOpen" :terminal-open="showTerminalDialog" + :shortcuts-ready="isConsoleActive && hidStatus === 'connected' && hidWs.connected.value && !hidWs.networkError.value && !hidWs.hidUnavailable.value" + :shortcut-input-busy="pressedKeys.length > 0 || activeModifierMask !== 0 || keyboardLockPending" + :shortcut-busy="shortcutBusy" + @execute-shortcut="executeShortcut" + @cancel-shortcut="cancelShortcut" @toggle-fullscreen="toggleFullscreen" @update:scale-mode="setVideoScaleMode" @toggle-stats="openStatsSheet" @@ -3509,6 +3560,7 @@ onUnmounted(() => { :caps-lock="keyboardLed.capsLock" :pressed-keys="pressedKeys" :consumer-enabled="virtualKeyboardConsumerEnabled" + :disabled="shortcutBusy" @key-down="handleVirtualKeyDown" @key-up="handleVirtualKeyUp" /> @@ -3528,7 +3580,7 @@ onUnmounted(() => { :num-lock="keyboardLed.numLock" :scroll-lock="keyboardLed.scrollLock" :keyboard-led-enabled="keyboardLedEnabled" - :keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending" + :keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending || shortcutBusy" :mouse-position="mousePosition" :debug-mode="false" @toggle-lock="toggleKeyboardLock"