From 4d3b1c835ef48e0d91bf6330c5df534e590a0b5c Mon Sep 17 00:00:00 2001 From: wj-xiao Date: Tue, 6 Jan 2026 13:58:53 +0800 Subject: [PATCH] feat: support custom keyboard shortcuts --- server/proto/hid.go | 22 ++ server/router/hid.go | 4 + server/service/hid/shortcut.go | 172 ++++++++++++ web/src/api/hid.ts | 21 ++ web/src/components/ui/kbd.tsx | 28 ++ web/src/i18n/locales/en.ts | 13 +- web/src/i18n/locales/zh.ts | 10 +- .../desktop/menu/keyboard/ctrl-alt-del.tsx | 38 --- web/src/pages/desktop/menu/keyboard/index.tsx | 8 +- .../desktop/menu/keyboard/shortcuts/index.tsx | 140 ++++++++++ .../menu/keyboard/shortcuts/recorder.tsx | 262 ++++++++++++++++++ .../menu/keyboard/shortcuts/shortcut.tsx | 58 ++++ .../desktop/menu/keyboard/shortcuts/types.ts | 9 + 13 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 server/service/hid/shortcut.go create mode 100644 web/src/components/ui/kbd.tsx delete mode 100644 web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx create mode 100644 web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx create mode 100644 web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx create mode 100644 web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx create mode 100644 web/src/pages/desktop/menu/keyboard/shortcuts/types.ts diff --git a/server/proto/hid.go b/server/proto/hid.go index b7bca75..80286ef 100644 --- a/server/proto/hid.go +++ b/server/proto/hid.go @@ -7,3 +7,25 @@ type GetHidModeRsp struct { type SetHidModeReq struct { Mode string `validate:"required"` // normal or hid-only } + +type ShortcutKey struct { + Code string `json:"code"` + Label string `json:"label"` +} + +type Shortcut struct { + ID string `json:"id"` + Keys []ShortcutKey `json:"keys"` +} + +type GetShortcutsRsp struct { + Shortcuts []Shortcut `json:"shortcuts"` +} + +type AddShortcutReq struct { + Keys []ShortcutKey `validate:"required"` +} + +type DeleteShortcutReq struct { + ID string `validate:"required"` +} diff --git a/server/router/hid.go b/server/router/hid.go index edfd358..1e68645 100644 --- a/server/router/hid.go +++ b/server/router/hid.go @@ -13,6 +13,10 @@ func hidRouter(r *gin.Engine) { api.POST("/hid/paste", service.Paste) // paste + api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts + api.POST("/hid/shortcut", service.AddShortcut) // add shortcut + api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut + api.GET("/hid/mode", service.GetHidMode) // get hid mode api.POST("/hid/mode", service.SetHidMode) // set hid mode api.POST("/hid/reset", service.ResetHid) // reset hid diff --git a/server/service/hid/shortcut.go b/server/service/hid/shortcut.go new file mode 100644 index 0000000..61848c9 --- /dev/null +++ b/server/service/hid/shortcut.go @@ -0,0 +1,172 @@ +package hid + +import ( + "encoding/json" + "errors" + "os" + "sync" + + "NanoKVM-Server/proto" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +var ( + shortcutFile = "/etc/kvm/shortcuts.json" + shortcutMutex = sync.RWMutex{} +) + +type ShortcutStore struct { + Shortcuts []proto.Shortcut `json:"shortcuts"` +} + +func (s *Service) GetShortcuts(c *gin.Context) { + var rsp proto.Response + + shortcuts, err := listShortcuts() + if err != nil { + log.Errorf("failed to get shortcuts: %v", err) + rsp.ErrRsp(c, -1, "get shortcuts failed") + return + } + + rsp.OkRspWithData(c, &proto.GetShortcutsRsp{ + Shortcuts: shortcuts, + }) + log.Debugf("get shortcuts success, total: %d", len(shortcuts)) +} + +func (s *Service) AddShortcut(c *gin.Context) { + var req proto.AddShortcutReq + var rsp proto.Response + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + shortcut, err := addShortcut(req.Keys) + if err != nil { + log.Errorf("failed to add shortcut: %v", err) + rsp.ErrRsp(c, -2, "add shortcut failed") + return + } + + rsp.OkRsp(c) + log.Debugf("add shortcut %s", shortcut.ID) +} + +func (s *Service) DeleteShortcut(c *gin.Context) { + var req proto.DeleteShortcutReq + var rsp proto.Response + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + err := deleteShortcut(req.ID) + if err != nil { + log.Errorf("failed to delete shortcut: %v", err) + rsp.ErrRsp(c, -2, "delete shortcut failed") + return + } + + rsp.OkRsp(c) + log.Debugf("delete shortcut %s", req.ID) +} + +func loadShortcuts() (*ShortcutStore, error) { + if _, err := os.Stat(shortcutFile); os.IsNotExist(err) { + return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil + } + + data, err := os.ReadFile(shortcutFile) + if err != nil { + return nil, err + } + + if len(data) == 0 { + return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil + } + + var store ShortcutStore + if err := json.Unmarshal(data, &store); err != nil { + return nil, err + } + + return &store, nil +} + +func saveShortcuts(store *ShortcutStore) error { + data, err := json.Marshal(store) + if err != nil { + return err + } + + return os.WriteFile(shortcutFile, data, 0644) +} + +func listShortcuts() ([]proto.Shortcut, error) { + shortcutMutex.RLock() + defer shortcutMutex.RUnlock() + + store, err := loadShortcuts() + if err != nil { + return nil, err + } + + return store.Shortcuts, nil +} + +func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) { + shortcutMutex.Lock() + defer shortcutMutex.Unlock() + + store, err := loadShortcuts() + if err != nil { + return nil, err + } + + shortcut := proto.Shortcut{ + ID: uuid.New().String(), + Keys: keys, + } + + store.Shortcuts = append(store.Shortcuts, shortcut) + + if err := saveShortcuts(store); err != nil { + return nil, err + } + + return &shortcut, nil +} + +func deleteShortcut(id string) error { + shortcutMutex.Lock() + defer shortcutMutex.Unlock() + + store, err := loadShortcuts() + if err != nil { + return err + } + + found := false + newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts)) + for _, shortcut := range store.Shortcuts { + if shortcut.ID == id { + found = true + continue + } + newShortcuts = append(newShortcuts, shortcut) + } + + if !found { + return errors.New("shortcut not found") + } + + store.Shortcuts = newShortcuts + return saveShortcuts(store) +} diff --git a/web/src/api/hid.ts b/web/src/api/hid.ts index 40feaee..ad73154 100644 --- a/web/src/api/hid.ts +++ b/web/src/api/hid.ts @@ -22,3 +22,24 @@ export function setHidMode(mode: string) { }; return http.post('/api/hid/mode', data); } + +// get shortcuts +export function getShortcuts() { + return http.get('/api/hid/shortcuts'); +} + +// add shortcut +export function addShortcut(keys: any[]) { + const data = { + keys + }; + return http.post('/api/hid/shortcut', data); +} + +// delete shortcut +export function deleteShortcut(id: string) { + const data = { + id + }; + return http.delete('/api/hid/shortcut', data); +} diff --git a/web/src/components/ui/kbd.tsx b/web/src/components/ui/kbd.tsx new file mode 100644 index 0000000..eef8fb0 --- /dev/null +++ b/web/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import clsx from 'clsx'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 282c1a5..61c5b1d 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -76,14 +76,23 @@ const en = { placeholder: 'Please input', submit: 'Submit', virtual: 'Keyboard', - ctrlaltdel: 'Ctrl+Alt+Del', readClipboard: 'Read from Clipboard', clipboardPermissionDenied: 'Clipboard permission denied. Please allow clipboard access in your browser.', clipboardReadError: 'Failed to read clipboard', dropdownEnglish: 'English', dropdownGerman: 'German', - dropdownRussian: 'Russian' + dropdownRussian: 'Russian', + shortcut: { + title: 'Shortcuts', + custom: 'Custom', + capture: 'Click here to capture shortcut', + clear: 'Clear', + save: 'Save', + captureTips: + 'Capturing system-level keys (such as the Windows key) requires full-screen permission.', + enterFullScreen: 'Toggle full-screen mode.' + } }, mouse: { title: 'Mouse', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 92d1d27..f7290c8 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -73,7 +73,15 @@ const zh = { placeholder: '请输入内容', submit: '确定', virtual: '虚拟键盘', - ctrlaltdel: 'Ctrl+Alt+Del' + shortcut: { + title: '快捷键', + custom: '自定义', + capture: '点击此处捕获快捷键', + clear: '清空', + save: '保存', + captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。', + enterFullScreen: '切换全屏模式。' + } }, mouse: { title: '鼠标', diff --git a/web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx b/web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx deleted file mode 100644 index d54e996..0000000 --- a/web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import clsx from 'clsx'; -import { OctagonMinus } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import { getKeycode, getModifierBit } from '@/lib/keymap.ts'; -import { client, MessageEvent } from '@/lib/websocket.ts'; - -export const CtrlAltDel = () => { - const { t } = useTranslation(); - - function sendCtrlAltDel() { - const ctrl = getModifierBit('ControlLeft')!; - const alt = getModifierBit('AltLeft')!; - const modifier = ctrl | alt; - - const del = getKeycode('Delete')!; - - send(modifier, del); - send(0, 0); - } - - function send(modifier: number, code: number) { - const data = new Uint8Array([MessageEvent.Keyboard, modifier, 0, code, 0, 0, 0, 0, 0]); - client.send(data); - } - - return ( -
- - {t('keyboard.ctrlaltdel')} -
- ); -}; diff --git a/web/src/pages/desktop/menu/keyboard/index.tsx b/web/src/pages/desktop/menu/keyboard/index.tsx index 8544a21..df4026f 100644 --- a/web/src/pages/desktop/menu/keyboard/index.tsx +++ b/web/src/pages/desktop/menu/keyboard/index.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'; import { MenuItem } from '@/components/menu-item.tsx'; -import { CtrlAltDel } from './ctrl-alt-del.tsx'; import { Paste } from './paste.tsx'; +import { Shortcuts } from './shortcuts'; import { VirtualKeyboard } from './virtual-keyboard.tsx'; export const Keyboard = () => { @@ -15,11 +15,11 @@ export const Keyboard = () => { title={t('keyboard.title')} icon={} content={ - <> +
- - + +
} /> ); diff --git a/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx b/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx new file mode 100644 index 0000000..7c0568c --- /dev/null +++ b/web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import { Divider, Popover } from 'antd'; +import { CommandIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/hid.ts'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +import { Recorder } from './recorder.tsx'; +import { Shortcut } from './shortcut.tsx'; +import type { Shortcut as ShortcutInterface } from './types.ts'; + +export const Shortcuts = () => { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [customShortcuts, setCustomShortcuts] = useState([]); + + const defaultShortcuts: ShortcutInterface[] = [ + { + keys: [ + { code: 'MetaLeft', label: 'Win' }, + { code: 'Tab', label: 'Tab' } + ] + }, + { + keys: [ + { code: 'ControlLeft', label: 'Ctrl' }, + { code: 'AltLeft', label: 'Alt' }, + { code: 'Delete', label: '⌫' } + ] + } + ]; + + useEffect(() => { + getShortcuts(); + }, [isOpen]); + + async function getShortcuts() { + try { + const rsp = await api.getShortcuts(); + if (rsp.code !== 0) { + console.log(rsp.msg); + return; + } + + setCustomShortcuts(rsp.data.shortcuts); + } catch (err) { + console.log(err); + } + } + + async function addShortcut(shortcut: ShortcutInterface) { + try { + const rsp = await api.addShortcut(shortcut.keys); + if (rsp.code !== 0) { + console.log(rsp.msg); + return; + } + + await getShortcuts(); + } catch (err) { + console.log(err); + } + } + + async function delShortcut(shortcut: ShortcutInterface) { + try { + if (!shortcut.id) return; + + const rsp = await api.deleteShortcut(shortcut.id); + if (rsp.code !== 0) { + console.log(rsp.msg); + return; + } + + await getShortcuts(); + } catch (err) { + console.log(err); + } + } + + function handleOpenChange(open: boolean) { + if (open) { + setIsOpen(true); + return; + } + if (isRecording) { + return; + } + setIsOpen(false); + } + + const content = ( + + {/* custom shortcuts */} + {customShortcuts.length > 0 && ( + <> + {customShortcuts.map((shortcut) => ( + + ))} + + + + )} + + {/* default shortcuts */} + {defaultShortcuts.map((shortcut, index) => ( + + ))} + + + + + + ); + + return ( + +
+ + {t('keyboard.shortcut.title')} +
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx b/web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx new file mode 100644 index 0000000..40ab864 --- /dev/null +++ b/web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx @@ -0,0 +1,262 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Divider, Input, InputRef, Modal } from 'antd'; +import { useSetAtom } from 'jotai'; +import { KeyboardIcon, Trash2Icon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { isModifier } from '@/lib/keymap.ts'; +import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts'; +import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx'; +import { ScrollArea } from '@/components/ui/scroll-area.tsx'; + +import { KeyInfo, Shortcut } from './types.ts'; + +const MAX_KEYS = 6; + +const SpecialKeyMap: Record = { + Space: 'Space', + Backspace: '⌫', + Enter: '↵', + Tab: 'Tab', + CapsLock: 'Caps', + Escape: 'Esc', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + Delete: 'Del', + Insert: 'Ins', + Home: 'Home', + End: 'End', + PageUp: 'PgUp', + PageDown: 'PgDn' +}; + +const PunctuationMap: Record = { + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Comma: ',', + Period: '.', + Slash: '/' +}; + +interface RecorderProps { + shortcuts: Shortcut[]; + addShortcut: (shortcut: Shortcut) => void; + delShortcut: (shortcut: Shortcut) => void; + setIsRecording: (isRecording: boolean) => void; +} + +export const Recorder = ({ + shortcuts, + addShortcut, + delShortcut, + setIsRecording +}: RecorderProps) => { + const { t } = useTranslation(); + const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [shortcutLabel, setShortcutLabel] = useState(''); + + const inputRef = useRef(null); + const recordedKeysRef = useRef([]); + + useEffect(() => { + setIsKeyboardEnable(!isModalOpen); + setIsRecording(isModalOpen); + + if (!isModalOpen) return; + + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 100); + + return () => { + clearTimeout(timer); + }; + }, [isModalOpen]); + + useEffect(() => { + if (!isFocused) return; + + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const { key, code } = event; + + const isRecorded = recordedKeysRef.current.some((k) => k.code === code); + if (isRecorded) return; + + if (recordedKeysRef.current.length >= MAX_KEYS) return; + + const keyInfo: KeyInfo = { + code: code, + label: formatKeyDisplay(key, code) + }; + + setShortcutLabel((prev) => (prev ? `${prev} + ${keyInfo.label}` : keyInfo.label)); + recordedKeysRef.current.push(keyInfo); + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isFocused]); + + const formatKeyDisplay = (key: string, code: string): string => { + if (isModifier(code)) { + if (code.startsWith('Control')) { + return 'Ctrl'; + } + if (code.startsWith('Shift')) { + return 'Shift'; + } + if (code.startsWith('Alt')) { + return 'Alt'; + } + if (code.startsWith('Meta')) { + return 'Win'; + } + } + + if (code.startsWith('Digit')) { + return code.replace('Digit', ''); + } + if (code.startsWith('Key')) { + return code.replace('Key', ''); + } + if (code.startsWith('Numpad')) { + const numpadKey = code.replace('Numpad', ''); + return `Num${numpadKey}`; + } + if (code.startsWith('F') && /^F\d+$/.test(code)) { + return code; // F1, F2, etc. + } + + if (SpecialKeyMap[code]) { + return SpecialKeyMap[code]; + } + if (PunctuationMap[code]) { + return PunctuationMap[code]; + } + + return key.toUpperCase(); + }; + + function saveShortcut() { + if (recordedKeysRef.current.length > 0) { + addShortcut({ keys: recordedKeysRef.current }); + } + + clearShortcut(); + } + + function clearShortcut() { + setShortcutLabel(''); + recordedKeysRef.current = []; + } + + function closeModal() { + clearShortcut(); + setIsModalOpen(false); + } + + function toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + // @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/lock + navigator.keyboard?.lock(); + } else { + document.exitFullscreen(); + // @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/unlock + navigator.keyboard?.unlock(); + } + } + + return ( + <> +
setIsModalOpen(true)} + > + {t('keyboard.shortcut.custom')} +
+ + +
+ {t('keyboard.shortcut.captureTips')} + + {t('keyboard.shortcut.enterFullScreen')} + +
+ +
+ } + value={shortcutLabel} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + + +
+ +
+ +
+ + {shortcuts.length > 0 && ( + <> + + + + {shortcuts.map((shortcut) => ( +
+
+ {shortcut.keys.map((key, index) => ( + + {key.label} + + ))} +
+ +
delShortcut(shortcut)} + > + +
+
+ ))} +
+ + )} +
+ + ); +}; diff --git a/web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx b/web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx new file mode 100644 index 0000000..87cd7a1 --- /dev/null +++ b/web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; + +import { KeyboardReport } from '@/lib/keyboard.ts'; +import { client, MessageEvent } from '@/lib/websocket.ts'; +import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx'; + +import type { Shortcut as ShortcutInterface } from './types.ts'; + +type ShortcutProps = { + shortcut: ShortcutInterface; +}; + +export const Shortcut = ({ shortcut }: ShortcutProps) => { + const [isLoading, setIsLoading] = useState(false); + + async function sendShortcut() { + const keyboard = new KeyboardReport(); + + shortcut.keys.forEach((key) => { + const report = keyboard.keyDown(key.code); + send(report); + }); + + const report = keyboard.reset(); + send(report); + } + + function send(report: Uint8Array) { + const data = new Uint8Array([MessageEvent.Keyboard, ...report]); + client.send(data); + } + + async function handleClick(): Promise { + if (isLoading) return; + setIsLoading(true); + + try { + await sendShortcut(); + } catch (err) { + console.log(err); + } finally { + setIsLoading(false); + } + } + + return ( +
+ {shortcut.keys.map((key, index) => ( + + {key.label} + + ))} +
+ ); +}; diff --git a/web/src/pages/desktop/menu/keyboard/shortcuts/types.ts b/web/src/pages/desktop/menu/keyboard/shortcuts/types.ts new file mode 100644 index 0000000..91ef618 --- /dev/null +++ b/web/src/pages/desktop/menu/keyboard/shortcuts/types.ts @@ -0,0 +1,9 @@ +export interface KeyInfo { + code: string; + label: string; +} + +export interface Shortcut { + id?: string; + keys: KeyInfo[]; +}