feat(hid): 添加 Linux 键盘与 SysRq 快捷键 (#307)

控制台增加独立快捷键菜单,提供常用组合键和 Linux SysRq 指令,虚拟键盘补充 Linux 布局。
通过可靠且有序的 WebSocket 发送组合键,取消或失败时逐一释放按键,防止修饰键残留。
执行期间避免物理键盘与虚拟键盘交错输入,断连、失焦和切换布局时停止执行。
修正焦点移动后的物理按键释放,并将原虚拟键盘组合键迁移到统一菜单。

验证:独立暂存快照前端构建通过;6 项快捷键单测及 5 组浏览器测试通过,覆盖组合键序列、失败清理、取消及键盘焦点。

Refs #307
This commit is contained in:
mofeng-git
2026-10-28 07:21:52 +08:00
parent 8d58119b77
commit 3c15168ddc
8 changed files with 371 additions and 127 deletions

View File

@@ -48,6 +48,8 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
import MsdDialog from '@/components/MsdDialog.vue' import MsdDialog from '@/components/MsdDialog.vue'
import VideoDisplayControls from '@/components/VideoDisplayControls.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' import type { ConsoleLayout } from '@/composables/useConsoleLayout'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -73,6 +75,9 @@ const props = defineProps<{
fullscreen?: boolean fullscreen?: boolean
statsOpen?: boolean statsOpen?: boolean
terminalOpen?: boolean terminalOpen?: boolean
shortcutsReady?: boolean
shortcutInputBusy?: boolean
shortcutBusy?: boolean
}>() }>()
const isSidebarLayout = computed(() => props.layout === 'sidebar') const isSidebarLayout = computed(() => props.layout === 'sidebar')
const isFloatingLayout = computed(() => props.layout === 'floating') const isFloatingLayout = computed(() => props.layout === 'floating')
@@ -106,6 +111,8 @@ const emit = defineEmits<{
(e: 'reset'): void (e: 'reset'): void
(e: 'wol', macAddress: string): void (e: 'wol', macAddress: string): void
(e: 'openTerminal'): void (e: 'openTerminal'): void
(e: 'executeShortcut', shortcut: KeyboardShortcut): void
(e: 'cancelShortcut'): void
}>() }>()
const pasteOpen = ref(false) const pasteOpen = ref(false)
@@ -114,6 +121,7 @@ const videoPopoverOpen = ref(false)
const hidPopoverOpen = ref(false) const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false) const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false) const msdDialogOpen = ref(false)
const shortcutsOpen = ref(false)
const mobileAtxOpen = ref(false) const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false) const mobilePasteOpen = ref(false)
@@ -259,6 +267,7 @@ watch([() => props.layout, floatingCollapsed, minimalDisplayControls], () => {
hidPopoverOpen.value = false hidPopoverOpen.value = false
audioPopoverOpen.value = false audioPopoverOpen.value = false
msdDialogOpen.value = false msdDialogOpen.value = false
shortcutsOpen.value = false
mobileAtxOpen.value = false mobileAtxOpen.value = false
mobilePasteOpen.value = false mobilePasteOpen.value = false
void observeLayout() void observeLayout()
@@ -521,6 +530,16 @@ const hasRightOverflow = computed(() => {
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')" @toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
/> />
<KeyboardShortcutsPopover
v-model:open="shortcutsOpen"
:ready="props.shortcutsReady === true"
:input-busy="props.shortcutInputBusy"
:busy="props.shortcutBusy"
:side="isSidebarLayout ? 'right' : 'bottom'"
@execute="emit('executeShortcut', $event)"
@cancel="emit('cancelShortcut')"
/>
<!-- Connection Stats - Adaptive --> <!-- Connection Stats - Adaptive -->
<div v-if="isVisible('stats')"> <div v-if="isVisible('stats')">
<TooltipProvider> <TooltipProvider>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Command, CircleHelp, Loader2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { focusConsolePanel } from '@/composables/useConsoleAppearance'
import {
keyboardShortcuts, sysrqDiagnostics, sysrqRecovery,
type KeyboardShortcut,
} from '@/lib/keyboardShortcuts'
const props = defineProps<{
ready: boolean
inputBusy?: boolean
busy?: boolean
side?: 'right' | 'bottom'
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
execute: [shortcut: KeyboardShortcut]
cancel: []
}>()
const { t } = useI18n()
const disabled = computed(() => !props.ready || props.inputBusy || props.busy)
const groups = [keyboardShortcuts, sysrqDiagnostics, sysrqRecovery]
function label(shortcut: KeyboardShortcut) {
return shortcut.id.startsWith('sysrq-') ? `Alt+SysRq+${shortcut.label}` : shortcut.label
}
function description(shortcut: KeyboardShortcut) {
return `${t(`shortcuts.actions.${shortcut.description}`)} · ${label(shortcut)}`
}
function select(shortcut: KeyboardShortcut) {
if (disabled.value) return
emit('execute', shortcut)
}
watch(open, value => {
if (!value && props.busy) emit('cancel')
})
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
data-fixed-action data-shortcut-surface
variant="ghost" size="sm"
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
:aria-label="t('actionbar.shortcuts')" :title="t('actionbar.shortcuts')"
>
<Loader2 v-if="busy" class="size-3.5 animate-spin sm:size-4" />
<Command v-else class="size-3.5 sm:size-4" />
<span class="hidden xl:inline">{{ t('actionbar.shortcuts') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent
data-shortcut-surface data-keyboard-shortcuts
class="console-config-panel shortcuts-panel w-[330px] max-h-[min(80dvh,var(--reka-popover-content-available-height))] overflow-y-auto p-3.5"
align="end" :side="side ?? 'bottom'" @open-auto-focus="focusConsolePanel"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('actionbar.shortcuts') }}</h4>
<button type="button" class="text-muted-foreground" :aria-label="t('shortcuts.close')" @click="open = false"><X class="size-4" /></button>
</div>
<template v-for="(group, index) in groups" :key="index">
<div v-if="index === 1" class="mt-3 flex items-center justify-between border-t pt-3 text-[13px] text-muted-foreground">
<span>Alt + SysRq +</span>
<a
href="https://docs.kernel.org/admin-guide/sysrq.html" target="_blank" rel="noopener noreferrer"
:title="t('shortcuts.sysrqHelp')" :aria-label="t('shortcuts.sysrqHelp')"
><CircleHelp class="size-3.5" /></a>
</div>
<div class="grid gap-1.5" :class="index === 0 ? 'grid-cols-2' : index === 1 ? 'mt-2 grid-cols-4' : 'mt-1.5 grid-cols-6'">
<button
v-for="shortcut in group" :key="shortcut.id" type="button"
class="shortcut-key" :class="{ 'shortcut-key--danger': shortcut.destructive }"
:data-shortcut="shortcut.id" :disabled="disabled"
:title="!ready ? t('shortcuts.unavailable') : inputBusy ? t('shortcuts.releaseKeys') : description(shortcut)"
:aria-label="description(shortcut)" @click="select(shortcut)"
>{{ shortcut.label }}</button>
</div>
</template>
</PopoverContent>
</Popover>
</template>
<style scoped>
.shortcut-key {
height: 35px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--muted) 45%, var(--background));
font-size: 13px;
white-space: nowrap;
transition: background-color 120ms;
}
.shortcut-key:hover:not(:disabled) { background: var(--muted); }
.shortcut-key:active:not(:disabled) { background: var(--accent); }
.shortcut-key:disabled { cursor: not-allowed; opacity: .45; }
.shortcut-key--danger {
color: var(--destructive);
border-color: color-mix(in srgb, var(--destructive) 20%, var(--border));
background: color-mix(in srgb, var(--destructive) 3%, var(--background));
}
</style>

View File

@@ -12,7 +12,6 @@ import {
consumerKeys, consumerKeys,
latchingKeys, latchingKeys,
modifiers, modifiers,
updateModifierMaskForKey,
type KeyName, type KeyName,
type ConsumerKeyName, type ConsumerKeyName,
} from '@/lib/keyboardMappings' } from '@/lib/keyboardMappings'
@@ -29,6 +28,7 @@ const props = defineProps<{
capsLock?: boolean capsLock?: boolean
pressedKeys?: CanonicalKey[] pressedKeys?: CanonicalKey[]
consumerEnabled?: boolean consumerEnabled?: boolean
disabled?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -88,7 +88,6 @@ const getBottomRow = () =>
const keyboardLayout = { const keyboardLayout = {
main: { main: {
default: [ default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', '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', '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', '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', 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
], ],
shift: [ shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause', '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', '(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', 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
@@ -142,10 +140,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta' : selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
return { return {
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
ControlLeft: 'Ctrl', ControlLeft: 'Ctrl',
ControlRight: 'Ctrl', ControlRight: 'Ctrl',
ShiftLeft: 'Shift', ShiftLeft: 'Shift',
@@ -258,6 +252,7 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
}) })
async function onMediaKeyPress(key: string) { async function onMediaKeyPress(key: string) {
if (props.disabled) return
if (key in consumerKeys) { if (key in consumerKeys) {
const usage = consumerKeys[key as ConsumerKeyName] const usage = consumerKeys[key as ConsumerKeyName]
try { try {
@@ -293,26 +288,7 @@ function updateKeyboardLayout() {
} }
async function onKeyDown(key: string) { async function onKeyDown(key: string) {
if (key === 'CtrlAltDelete') { if (props.disabled) return
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
}
const cleanKey = key.replace(/[()]/g, '') 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() { function updateKeyboardButtonTheme() {
const downKeys = keyNamesForDownKeys.value.join(' ') const downKeys = keyNamesForDownKeys.value.join(' ')
const buttonTheme = [ const buttonTheme = [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
{ {
class: 'down-key', class: 'down-key',
buttons: downKeys, buttons: downKeys,
@@ -460,12 +391,6 @@ function initKeyboards() {
theme: 'hg-theme-default hg-layout-default vkb-keyboard', theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown, onKeyPress: onKeyDown,
onKeyReleased: onKeyUp, onKeyReleased: onKeyUp,
buttonTheme: [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
],
disableButtonHold: true, disableButtonHold: true,
preventMouseDownDefault: true, preventMouseDownDefault: true,
preventMouseUpDefault: true, preventMouseUpDefault: true,
@@ -559,7 +484,7 @@ watch(() => props.attached, (value) => {
onMounted(() => { onMounted(() => {
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null 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 selectedOs.value = savedOs
} }
@@ -617,7 +542,7 @@ onUnmounted(() => {
</Button> </Button>
<div class="vkb-os-selector"> <div class="vkb-os-selector">
<Button <Button
v-for="os in (['windows', 'mac', 'android'] as KeyboardOsType[])" v-for="os in (['windows', 'linux', 'mac', 'android'] as KeyboardOsType[])"
:key="os" :key="os"
class="vkb-os-btn" class="vkb-os-btn"
variant="ghost" variant="ghost"
@@ -625,7 +550,7 @@ onUnmounted(() => {
:class="{ 'vkb-os-btn--active': selectedOs === os }" :class="{ 'vkb-os-btn--active': selectedOs === os }"
@click.stop="switchOsLayout(os)" @click.stop="switchOsLayout(os)"
> >
{{ os === 'windows' ? 'Win' : os === 'mac' ? 'Mac' : 'Android' }} {{ os === 'windows' ? 'Win' : os === 'linux' ? 'Linux' : os === 'mac' ? 'Mac' : 'Android' }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -702,15 +627,6 @@ onUnmounted(() => {
white-space: nowrap; 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 */ /* Pressed keys */
.vkb .simple-keyboard .hg-button.down-key { .vkb .simple-keyboard .hg-button.down-key {
@@ -735,15 +651,8 @@ onUnmounted(() => {
margin-bottom: 0; margin-bottom: 0;
} }
/* First row (macros) - left aligned */ /* Function row spacing */
.kb-main-container .hg-row:first-child { .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; margin-bottom: 8px;
} }
@@ -1119,11 +1028,6 @@ html.dark .hg-theme-default .hg-button.down-key,
min-width: 0; 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="Backspace"],
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"], .vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
@@ -1182,11 +1086,6 @@ html.dark .hg-theme-default .hg-button.down-key,
border-radius: 4px; 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="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"], .vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
@@ -1234,10 +1133,6 @@ html.dark .hg-theme-default .hg-button.down-key,
font-size: 12px; font-size: 12px;
} }
.vkb--floating :deep(.simple-keyboard .hg-button.combination-key) {
height: 26px;
font-size: 10px;
}
/* Animation */ /* Animation */
.keyboard-fade-enter-active, .keyboard-fade-enter-active,

View File

@@ -109,6 +109,36 @@ export default {
selectResolution: 'Select resolution...', selectResolution: 'Select resolution...',
selectFps: 'Select FPS...', 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: { common: {
loading: 'Loading...', loading: 'Loading...',
save: 'Save', save: 'Save',
@@ -236,6 +266,7 @@ export default {
power: 'Power', power: 'Power',
keyboard: 'Virtual Keyboard', keyboard: 'Virtual Keyboard',
keyboardTip: 'Show virtual keyboard', keyboardTip: 'Show virtual keyboard',
shortcuts: 'Shortcuts',
webTerminal: 'Web Terminal', webTerminal: 'Web Terminal',
stats: 'Stats', stats: 'Stats',
statsTip: 'View connection statistics', statsTip: 'View connection statistics',

View File

@@ -109,6 +109,36 @@ export default {
selectResolution: '选择分辨率...', selectResolution: '选择分辨率...',
selectFps: '选择帧率...', 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: { common: {
loading: '加载中...', loading: '加载中...',
save: '保存', save: '保存',
@@ -236,6 +266,7 @@ export default {
power: '电源', power: '电源',
keyboard: '虚拟键盘', keyboard: '虚拟键盘',
keyboardTip: '显示虚拟键盘', keyboardTip: '显示虚拟键盘',
shortcuts: '快捷键',
webTerminal: '网页终端', webTerminal: '网页终端',
stats: '连接统计', stats: '连接统计',
statsTip: '查看连接状态', statsTip: '查看连接状态',

View File

@@ -1,8 +1,9 @@
export type KeyboardOsType = 'windows' | 'mac' | 'android' export type KeyboardOsType = 'windows' | 'linux' | 'mac' | 'android'
export const osBottomRows: Record<KeyboardOsType, string[]> = { export const osBottomRows: Record<KeyboardOsType, string[]> = {
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'], 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'], mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'], android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
} }

View File

@@ -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<void>
function checkCancelled(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new DOMException('Cancelled', 'AbortError')
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
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)
}

View File

@@ -20,6 +20,7 @@ import { streamApi, hidApi, atxApi, atxConfigApi, authApi, uacApi } from '@/api'
import { CanonicalKey, HidBackend } from '@/types/generated' import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { executeKeyboardShortcut, type KeyboardShortcut } from '@/lib/keyboardShortcuts'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { cn, generateUUID } from '@/lib/utils' import { cn, generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps' import { formatFpsValue } from '@/lib/fps'
@@ -167,6 +168,8 @@ const keyboardLed = computed(() => ({
})) }))
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false) const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
const keyboardLockPending = ref(false) const keyboardLockPending = ref(false)
const shortcutBusy = ref(false)
let shortcutController: AbortController | null = null
const activeModifierMask = ref(0) const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 }) const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = 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}`)) 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) { async function toggleKeyboardLock(key: CanonicalKey.CapsLock | CanonicalKey.NumLock | CanonicalKey.ScrollLock) {
if (!keyboardLedEnabled.value || hidStatus.value !== 'connected' 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 keyboardLockPending.value = true
try { try {
@@ -2127,6 +2174,8 @@ function shouldBlockKey(e: KeyboardEvent): boolean {
} }
function handleKeyDown(e: KeyboardEvent) { 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 const container = videoContainerRef.value
if (!container) return if (!container) return
@@ -2156,22 +2205,18 @@ function handleKeyDown(e: KeyboardEvent) {
} }
function handleKeyUp(e: KeyboardEvent) { function handleKeyUp(e: KeyboardEvent) {
const container = videoContainerRef.value if (shortcutBusy.value) return
if (!container) return const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
// A local Tab/Enter can move focus before keyup. Only release keys whose
if (!isFullscreen.value && !container.contains(document.activeElement)) return // keydown was actually forwarded; do release them after focus moves away.
if (canonicalKey === undefined || !pressedKeys.value.includes(canonicalKey)) return
if (shouldBlockKey(e)) { const localShortcut = document.querySelector('[data-keyboard-shortcuts]')
|| (e.target instanceof Element && e.target.closest('[data-shortcut-surface]'))
if (!localShortcut && shouldBlockKey(e)) {
e.preventDefault() e.preventDefault()
e.stopPropagation() 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) pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false) const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
@@ -2837,6 +2882,7 @@ function handleFullscreenChange() {
} }
function handleBlur() { function handleBlur() {
cancelShortcut()
resetTouchInput() resetTouchInput()
pressedKeys.value = [] pressedKeys.value = []
activeModifierMask.value = 0 activeModifierMask.value = 0
@@ -3266,6 +3312,11 @@ onUnmounted(() => {
:fullscreen="isFullscreen" :fullscreen="isFullscreen"
:stats-open="statsSheetOpen" :stats-open="statsSheetOpen"
:terminal-open="showTerminalDialog" :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" @toggle-fullscreen="toggleFullscreen"
@update:scale-mode="setVideoScaleMode" @update:scale-mode="setVideoScaleMode"
@toggle-stats="openStatsSheet" @toggle-stats="openStatsSheet"
@@ -3509,6 +3560,7 @@ onUnmounted(() => {
:caps-lock="keyboardLed.capsLock" :caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys" :pressed-keys="pressedKeys"
:consumer-enabled="virtualKeyboardConsumerEnabled" :consumer-enabled="virtualKeyboardConsumerEnabled"
:disabled="shortcutBusy"
@key-down="handleVirtualKeyDown" @key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp" @key-up="handleVirtualKeyUp"
/> />
@@ -3528,7 +3580,7 @@ onUnmounted(() => {
:num-lock="keyboardLed.numLock" :num-lock="keyboardLed.numLock"
:scroll-lock="keyboardLed.scrollLock" :scroll-lock="keyboardLed.scrollLock"
:keyboard-led-enabled="keyboardLedEnabled" :keyboard-led-enabled="keyboardLedEnabled"
:keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending" :keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending || shortcutBusy"
:mouse-position="mousePosition" :mouse-position="mousePosition"
:debug-mode="false" :debug-mode="false"
@toggle-lock="toggleKeyboardLock" @toggle-lock="toggleKeyboardLock"