mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(hid): 添加 Linux 键盘与 SysRq 快捷键 (#307)
控制台增加独立快捷键菜单,提供常用组合键和 Linux SysRq 指令,虚拟键盘补充 Linux 布局。 通过可靠且有序的 WebSocket 发送组合键,取消或失败时逐一释放按键,防止修饰键残留。 执行期间避免物理键盘与虚拟键盘交错输入,断连、失焦和切换布局时停止执行。 修正焦点移动后的物理按键释放,并将原虚拟键盘组合键迁移到统一菜单。 验证:独立暂存快照前端构建通过;6 项快捷键单测及 5 组浏览器测试通过,覆盖组合键序列、失败清理、取消及键盘焦点。 Refs #307
This commit is contained in:
@@ -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')"
|
||||
/>
|
||||
|
||||
<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 -->
|
||||
<div v-if="isVisible('stats')">
|
||||
<TooltipProvider>
|
||||
|
||||
107
web/src/components/KeyboardShortcutsPopover.vue
Normal file
107
web/src/components/KeyboardShortcutsPopover.vue
Normal 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>
|
||||
@@ -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<Record<string, string>>(() => {
|
||||
: 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<Record<string, string>>(() => {
|
||||
})
|
||||
|
||||
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(() => {
|
||||
</Button>
|
||||
<div class="vkb-os-selector">
|
||||
<Button
|
||||
v-for="os in (['windows', 'mac', 'android'] as KeyboardOsType[])"
|
||||
v-for="os in (['windows', 'linux', 'mac', 'android'] as KeyboardOsType[])"
|
||||
:key="os"
|
||||
class="vkb-os-btn"
|
||||
variant="ghost"
|
||||
@@ -625,7 +550,7 @@ onUnmounted(() => {
|
||||
:class="{ 'vkb-os-btn--active': selectedOs === os }"
|
||||
@click.stop="switchOsLayout(os)"
|
||||
>
|
||||
{{ os === 'windows' ? 'Win' : os === 'mac' ? 'Mac' : 'Android' }}
|
||||
{{ os === 'windows' ? 'Win' : os === 'linux' ? 'Linux' : os === 'mac' ? 'Mac' : 'Android' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '查看连接状态',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
||||
export type KeyboardOsType = 'windows' | 'linux' | 'mac' | 'android'
|
||||
|
||||
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
||||
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'],
|
||||
}
|
||||
|
||||
108
web/src/lib/keyboardShortcuts.ts
Normal file
108
web/src/lib/keyboardShortcuts.ts
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user