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')}
+
+
+
+
+
+
+ }
+ 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[];
+}