feat: support custom keyboard shortcuts

This commit is contained in:
wj-xiao
2026-01-06 13:58:53 +08:00
parent e3558933b1
commit 4d3b1c835e
13 changed files with 740 additions and 45 deletions

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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);
}

View File

@@ -0,0 +1,28 @@
import clsx from 'clsx';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={clsx(
'pointer-events-none inline-flex h-5 w-fit min-w-9 select-none items-center justify-center gap-1 rounded border-b border-b-neutral-600 bg-neutral-700/70 px-1 font-sans text-xs font-medium text-neutral-300',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:text-background [[data-slot=tooltip-content]_&]:bg-background/10',
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={clsx('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -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',

View File

@@ -73,7 +73,15 @@ const zh = {
placeholder: '请输入内容',
submit: '确定',
virtual: '虚拟键盘',
ctrlaltdel: 'Ctrl+Alt+Del'
shortcut: {
title: '快捷键',
custom: '自定义',
capture: '点击此处捕获快捷键',
clear: '清空',
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
}
},
mouse: {
title: '鼠标',

View File

@@ -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 (
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70'
)}
onClick={sendCtrlAltDel}
>
<OctagonMinus size={18} />
<span>{t('keyboard.ctrlaltdel')}</span>
</div>
);
};

View File

@@ -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={<KeyboardIcon size={18} />}
content={
<>
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<CtrlAltDel />
</>
<Shortcuts />
</div>
}
/>
);

View File

@@ -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<ShortcutInterface[]>([]);
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 = (
<ScrollArea className="max-w-[400px] [&>[data-radix-scroll-area-viewport]]:max-h-[350px]">
{/* custom shortcuts */}
{customShortcuts.length > 0 && (
<>
{customShortcuts.map((shortcut) => (
<Shortcut key={shortcut.id} shortcut={shortcut}></Shortcut>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
</>
)}
{/* default shortcuts */}
{defaultShortcuts.map((shortcut, index) => (
<Shortcut key={index} shortcut={shortcut}></Shortcut>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
<Recorder
shortcuts={customShortcuts}
addShortcut={addShortcut}
delShortcut={delShortcut}
setIsRecording={setIsRecording}
/>
</ScrollArea>
);
return (
<Popover
content={content}
trigger="hover"
placement="rightTop"
align={{ offset: [14, 0] }}
open={isOpen}
onOpenChange={handleOpenChange}
arrow={false}
>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70">
<CommandIcon size={18} />
<span>{t('keyboard.shortcut.title')}</span>
</div>
</Popover>
);
};

View File

@@ -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<string, string> = {
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<string, string> = {
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<InputRef | null>(null);
const recordedKeysRef = useRef<KeyInfo[]>([]);
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 (
<>
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/60"
onClick={() => setIsModalOpen(true)}
>
<span>{t('keyboard.shortcut.custom')}</span>
</div>
<Modal
width={500}
title={t('keyboard.shortcut.title')}
keyboard={false}
footer={null}
open={isModalOpen}
onCancel={closeModal}
>
<div>
<span className="text-neutral-500">{t('keyboard.shortcut.captureTips')}</span>
<a className="px-1 text-blue-500/80" onClick={toggleFullscreen}>
{t('keyboard.shortcut.enterFullScreen')}
</a>
</div>
<div className="flex items-center space-x-0.5 py-6">
<Input
ref={inputRef}
placeholder={t('keyboard.shortcut.capture')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
value={shortcutLabel}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<Button onClick={clearShortcut}>{t('keyboard.shortcut.clear')}</Button>
</div>
<div className="flex w-full justify-center pb-3">
<Button type="primary" className="min-w-24" onClick={saveShortcut}>
{t('keyboard.shortcut.save')}
</Button>
</div>
{shortcuts.length > 0 && (
<>
<Divider />
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[300px]">
{shortcuts.map((shortcut) => (
<div
key={shortcut.id}
className="flex items-center justify-between rounded p-2 hover:bg-neutral-700/50"
>
<div className="flex items-center space-x-1">
{shortcut.keys.map((key, index) => (
<KbdGroup key={index}>
<Kbd>{key.label}</Kbd>
</KbdGroup>
))}
</div>
<div
className="flex size-[20px] cursor-pointer items-center justify-center rounded-sm text-neutral-500 hover:text-red-500"
onClick={() => delShortcut(shortcut)}
>
<Trash2Icon size={16} />
</div>
</div>
))}
</ScrollArea>
</>
)}
</Modal>
</>
);
};

View File

@@ -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<void> {
if (isLoading) return;
setIsLoading(true);
try {
await sendShortcut();
} catch (err) {
console.log(err);
} finally {
setIsLoading(false);
}
}
return (
<div
className="flex h-[32px] w-full cursor-pointer items-center space-x-1 rounded px-3 hover:bg-neutral-700/30"
onClick={handleClick}
>
{shortcut.keys.map((key, index) => (
<KbdGroup key={index}>
<Kbd>{key.label}</Kbd>
</KbdGroup>
))}
</div>
);
};

View File

@@ -0,0 +1,9 @@
export interface KeyInfo {
code: string;
label: string;
}
export interface Shortcut {
id?: string;
keys: KeyInfo[];
}