Compare commits

..

37 Commits
2.3.1 ... 2.3.4

Author SHA1 Message Date
wj-xiao
4af32fc703 release: nanokvm@2.3.4 2026-01-26 17:56:52 +08:00
wj-xiao
a611e5ef56 feat: support keyboard leader key functionality 2026-01-26 17:10:16 +08:00
wj-xiao
94051c3037 chore: added image version number v1.4.2 2026-01-26 11:23:17 +08:00
wj-xiao
d5cd706078 fix: compatible with non-standard modifier keys 2026-01-26 11:12:19 +08:00
wj-xiao
7460ebfd07 fix: the menu bar cannot be closed 2026-01-26 10:46:57 +08:00
wj-xiao
e3877308a0 fix: trigger update check when switching preview updates 2026-01-26 10:28:27 +08:00
wenjie
7c28fb8124 Merge pull request #737 from kmw0410/main
update korean translation
2026-01-26 10:08:02 +08:00
wenjie
b18873364f Merge pull request #733 from j796160836/feature/update_zh_tw
Update zh_tw translation
2026-01-26 10:07:37 +08:00
kmw0410
016a7d8a51 update korean translation 2026-01-25 22:04:24 +09:00
Johnny Sung
585f1bc2b5 feat: update zh_tw translation 2026-01-20 01:59:53 +08:00
wj-xiao
4fcc4c3747 release: nanokvm@2.3.3 2026-01-16 15:32:14 +08:00
wj-xiao
0d2c52adc3 feat: the menu bar supports setting the display mode 2026-01-16 11:47:56 +08:00
wj-xiao
af256e7f92 fix: compatible with IME composition 2026-01-15 17:04:17 +08:00
wj-xiao
84830421ee fix: compatible with Windows AltGr key 2026-01-15 16:45:48 +08:00
wj-xiao
9b278cdb0e fix: compatible with macOS's command key combinations 2026-01-14 17:58:25 +08:00
wj-xiao
9b40a5cf86 chore: update eslint 2026-01-14 17:58:25 +08:00
wj-xiao
b8d980be33 fix: the IntlBackslash key does not work on German virtual keyboard 2026-01-14 17:58:25 +08:00
wenjie
195037af87 Merge pull request #728 from imguoguo/main
ci: add missing parameters and push when issue closed
2026-01-14 15:48:07 +08:00
Guoguo
c98000069a ci: add missing parameters and push when issue closed 2026-01-14 07:43:19 +00:00
wenjie
2bd277b57d Merge pull request #725 from kmw0410/main
update korean translation
2026-01-14 10:30:40 +08:00
wj-xiao
4852ed813b chore: bump react-router-dom from 6.27.0 to 6.30.3 2026-01-14 10:27:08 +08:00
wj-xiao
fb08df9e4e fix: correct erroneous release date 2026-01-12 19:53:26 +08:00
kmw0410
64f3b49e21 update korean translation 2026-01-12 09:11:56 +09:00
wj-xiao
329c0a0001 release: nanokvm@2.3.2 2026-01-08 19:15:52 +08:00
wenjie
c28273d1f0 Merge pull request #721 from imguoguo/ci-use-jq
ci: use jq to format json
2026-01-08 17:33:40 +08:00
Guoguo
1d22d75889 ci: use jq to format json 2026-01-08 09:16:27 +00:00
wj-xiao
6ef8bb74e3 ui: update menu bar 2026-01-08 14:45:50 +08:00
wj-xiao
0e00760474 fix: fix touchscreen scrolling 2026-01-08 14:22:22 +08:00
wenjie
d969bd68ec Merge pull request #705 from imguoguo/add-issue-tracker
feat: add issue tracker
2026-01-06 16:15:31 +08:00
Guoguo
1e5f4cb24b feat: add issue tracker 2026-01-06 08:09:51 +00:00
wj-xiao
4d3b1c835e feat: support custom keyboard shortcuts 2026-01-06 13:58:53 +08:00
wj-xiao
e3558933b1 ui: menu bar stops auto-hiding when submenu is open 2026-01-06 11:08:57 +08:00
wj-xiao
7762706200 fix: set minimum width for mouse speed component 2026-01-05 18:19:06 +08:00
wj-xiao
b66707c959 refactor: support delete request 2026-01-05 17:45:38 +08:00
wj-xiao
a3440cf867 ui: update menu bar 2026-01-05 17:37:44 +08:00
wj-xiao
1b7021d64b refactor: refactor keyboard, mouse, and WebSocket 2026-01-05 16:49:17 +08:00
BuGu
687dd3bfc4 Fix the issue of old IP residue in static IP configuration 2026-01-04 18:18:00 +08:00
78 changed files with 3961 additions and 1022 deletions

68
.github/workflows/issue-tracker.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Issue Tracker
on:
issues:
types: [opened, reopened, closed]
jobs:
track-issue:
name: Track Issue Updates
runs-on: ubuntu-latest
steps:
- name: Send Issue Data to API
env:
RAW_TITLE: ${{ github.event.issue.title }}
RAW_BODY: ${{ github.event.issue.body }}
RAW_LABELS: ${{ toJSON(github.event.issue.labels) }}
RAW_ASSIGNEES: ${{ toJSON(github.event.issue.assignees) }}
API_URL: ${{ secrets.ISSUE_TRACKER_API_URL }}
VERIFY_TOKEN: ${{ secrets.VERIFY_TOKEN }}
run: |
ISSUE_DATA=$(jq -n \
--arg action "${{ github.event.action }}" \
--arg id "${{ github.event.issue.id }}" \
--arg num "${{ github.event.issue.number }}" \
--arg title "$RAW_TITLE" \
--arg body "$RAW_BODY" \
--arg state "${{ github.event.issue.state }}" \
--arg created_at "${{ github.event.issue.created_at }}" \
--arg updated_at "${{ github.event.issue.updated_at }}" \
--arg closed_at "${{ github.event.issue.closed_at }}" \
--arg url "${{ github.event.issue.html_url }}" \
--arg user_login "${{ github.event.issue.user.login }}" \
--arg repository "${{ github.repository }}" \
--argjson labels "$RAW_LABELS" \
--argjson assignees "$RAW_ASSIGNEES" \
'{
action: $action,
issue: {
id: ($id | tonumber),
number: ($num | tonumber),
title: $title,
body: $body,
state: $state,
created_at: $created_at,
updated_at: $updated_at,
closed_at: $closed_at,
url: $url,
user: { login: $user_login },
labels: $labels,
assignees: $assignees
},
repository: { name: $repository },
}')
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Token: $VERIFY_TOKEN" \
-d "$ISSUE_DATA")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Successfully sent issue data to API"
else
echo "Failed to send issue data to API"
exit 1
fi

View File

@@ -1,3 +1,60 @@
## 2.3.4 (2026-01-26)
### Features
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
### Bug Fixes
* Fixed an issue where the Right Shift key was not being recognized on Windows system
* Fixed a bug where the menu bar would fail to close correctly
### Localization
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
## 2.3.3 (2026-01-16)
### Features
* Added support for setting the display mode of the menu bar
### Bug Fixes
* Fixed a keyboard issue where IME composition events were not handled correctly
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
### Localization
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
### Chores
* Bump react-router-dom from 6.27.0 to 6.30.3
* Updated eslint
## 2.3.2 (2026-01-08)
### Features
* Added support for custom keyboard shortcuts
* Added support for mouse Forward and Back buttons (reboot required)
* Added support for touchscreen mouse operation
* The menu bar now supports auto-hide and drag
### Bug Fixes
* Fixed an issue where the previous IP address was not released after configuring a static IP
### Performance
* Refactored the keyboard to support simultaneous keystrokes and a wider range of international layouts (reboot required)
* Refactored the mouse to improve response latency
* Refactored the WebSocket module to transmit keyboard and mouse data using the standard HID format
## 2.3.1 (2025-12-26)
### Features

View File

@@ -60,7 +60,7 @@ start_usb_dev(){
fi
echo 1 > functions/hid.GS0/protocol
echo 8 > functions/hid.GS0/report_length
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\xE7\\x05\\x07\\x19\\x00\\x29\\xE7\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
ln -s functions/hid.GS0 configs/c.1
# mouse
@@ -90,7 +90,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS2/protocol
echo 6 > functions/hid.GS2/report_length
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
ln -s functions/hid.GS2 configs/c.1
fi

View File

@@ -19,6 +19,7 @@ EOF
}
start() {
ip addr flush dev eth0
if [ -e /boot/eth.nodhcp ]; then
while IFS= read -r line || [ -n "$line" ]; do
line=$(clean_line "$line")

View File

@@ -7,3 +7,33 @@ 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"`
}
type SetLeaderKeyReq struct {
Key string `validate:"omitempty"`
}
type GetLeaderKeyRsp struct {
Key string `json:"key"`
}

View File

@@ -13,6 +13,13 @@ 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/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
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

@@ -18,9 +18,9 @@ type Hid struct {
}
const (
HID0 = "/dev/hidg0"
HID1 = "/dev/hidg1"
HID2 = "/dev/hidg2"
HID0 = "/dev/hidg0" // Keyboard
HID1 = "/dev/hidg1" // Mouse (Relative Mode)
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
)
var (
@@ -95,16 +95,22 @@ func (h *Hid) Close() {
}
func (h *Hid) WriteHid0(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.kbMutex.Lock()
_ = h.g0.SetWriteDeadline(deadline)
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
if errors.Is(err, os.ErrClosed) {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Debugf("write to %s failed: %s", HID0, err)
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID0)
default:
log.Errorf("write to %s failed: %s", HID0, err)
}
return
}

View File

@@ -1,15 +1,16 @@
package hid
func (h *Hid) Keyboard(queue <-chan []int) {
for event := range queue {
code := byte(event[0])
import (
log "github.com/sirupsen/logrus"
)
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
func (h *Hid) Keyboard(queue <-chan []byte) {
for event := range queue {
if len(event) != 8 {
log.Debugf("invalid keyboard event: %v", event)
continue
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.WriteHid0(data)
h.WriteHid0(event)
}
}

View File

@@ -0,0 +1,63 @@
package hid
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
LeaderKeyFile = "/etc/kvm/leader-key"
)
func (s *Service) SetLeaderKey(c *gin.Context) {
var req proto.SetLeaderKeyReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Key == "" {
err := os.Remove(LeaderKeyFile)
if err != nil && !os.IsNotExist(err) {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set leader key: %s", req.Key)
}
func (s *Service) GetLeaderKey(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(LeaderKeyFile)
if err != nil {
if os.IsNotExist(err) {
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: "",
})
return
}
rsp.ErrRsp(c, -1, "read leader key failed")
return
}
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get leader key successful")
}

View File

@@ -1,82 +1,18 @@
package hid
import (
"encoding/binary"
log "github.com/sirupsen/logrus"
)
const (
MouseUp = iota
MouseDown
MouseMoveAbsolute
MouseMoveRelative
MouseScroll
)
var mouseButtonMap = map[byte]bool{
0x01: true,
0x02: true,
0x04: true,
}
func (h *Hid) Mouse(queue <-chan []int) {
func (h *Hid) Mouse(queue <-chan []byte) {
for event := range queue {
switch event[0] {
case MouseDown:
h.mouseDown(event)
case MouseUp:
h.mouseUp()
case MouseMoveAbsolute:
h.mouseMoveAbsolute(event)
case MouseMoveRelative:
h.mouseMoveRelative(event)
case MouseScroll:
h.mouseScroll(event)
switch len(event) {
case 4:
h.WriteHid1(event)
case 6:
h.WriteHid2(event)
default:
log.Debugf("invalid mouse event: %v", event)
}
}
}
func (h *Hid) mouseDown(event []int) {
button := byte(event[1])
if _, ok := mouseButtonMap[button]; !ok {
log.Errorf("invalid mouse button: %v", event)
return
}
data := []byte{button, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseUp() {
data := []byte{0, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseScroll(event []int) {
direction := 0x01
if event[3] < 0 {
direction = -0x1
}
data := []byte{0, 0, 0, byte(direction)}
h.WriteHid1(data)
}
func (h *Hid) mouseMoveAbsolute(event []int) {
x := make([]byte, 2)
y := make([]byte, 2)
binary.LittleEndian.PutUint16(x, uint16(event[2]))
binary.LittleEndian.PutUint16(y, uint16(event[3]))
data := []byte{0, x[0], x[1], y[0], y[1], 0}
h.WriteHid2(data)
}
func (h *Hid) mouseMoveRelative(event []int) {
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
h.WriteHid1(data)
}

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

@@ -1,11 +1,12 @@
package vm
import (
"NanoKVM-Server/config"
"fmt"
"os"
"strings"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
"2024-11-13-09-59-9c961a.img": "v1.3.0",
"2025-02-17-19-08-3649fe.img": "v1.4.0",
"2025-04-17-14-21-98d17d.img": "v1.4.1",
"2026-01-05-1_4_1.img": "v1.4.2",
}
func (s *Service) GetInfo(c *gin.Context) {

109
server/service/ws/client.go Normal file
View File

@@ -0,0 +1,109 @@
package ws
import (
"encoding/json"
"time"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
const (
Heartbeat = iota
KeyboardEvent
MouseEvent
)
func NewClient(ws *websocket.Conn) *Client {
client := &Client{
ws: ws,
hid: hid.GetHid(),
keyboard: make(chan []byte, 200),
mouse: make(chan []byte, 200),
lastHeartbeat: time.Time{},
}
client.hid.Open()
return client
}
func (c *Client) Start() {
defer c.Close()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *Client) Read() error {
var zeroTime time.Time
_ = c.ws.SetReadDeadline(zeroTime)
for {
messageType, data, err := c.ws.ReadMessage()
if err != nil {
return err
}
log.Debugf("received message %d: %v", messageType, data)
switch data[0] {
case Heartbeat:
c.UpdateHeartbeat()
case KeyboardEvent:
writeQueue(c.keyboard, data[1:])
case MouseEvent:
writeQueue(c.mouse, data[1:])
}
}
}
func (c *Client) Write(event string, data string) error {
message := &Message{
Type: event,
Data: data,
}
messageByte, err := json.Marshal(message)
if err != nil {
log.Errorf("failed to marshal message: %s", err)
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.ws.WriteMessage(websocket.TextMessage, messageByte)
}
func (c *Client) UpdateHeartbeat() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.lastHeartbeat = time.Now()
}
func (c *Client) Close() {
_ = c.ws.Close()
closeQueue(c.keyboard)
closeQueue(c.mouse)
log.Debug("websocket disconnected")
}
func writeQueue(queue chan []byte, data []byte) {
queue <- data
jiggler.GetJiggler().Update()
}
func closeQueue(queue chan []byte) {
for range queue {
}
close(queue)
}

View File

@@ -0,0 +1,46 @@
package ws
import (
"sync"
"github.com/gorilla/websocket"
)
var (
globalManager *Manager
managerOnce sync.Once
)
func GetManager() *Manager {
managerOnce.Do(func() {
globalManager = &Manager{
clients: make(map[*websocket.Conn]*Client),
mutex: sync.RWMutex{},
}
})
return globalManager
}
func (m *Manager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.clients[ws] = client
}
func (m *Manager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
delete(m.clients, ws)
}
func (m *Manager) GetClients() []*Client {
m.mutex.Lock()
defer m.mutex.Unlock()
clients := make([]*Client, 0, len(m.clients))
for _, c := range m.clients {
clients = append(clients, c)
}
return clients
}

View File

@@ -1,6 +0,0 @@
package ws
type Stream struct {
Type string `json:"type"`
State int `json:"state"`
}

View File

@@ -1,7 +1,41 @@
package ws
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type Service struct{}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func NewService() *Service {
return &Service{}
}
func (s *Service) Connect(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := NewClient(ws)
manager := GetManager()
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
client.Start()
}

View File

@@ -0,0 +1,29 @@
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
"NanoKVM-Server/service/hid"
)
type Manager struct {
clients map[*websocket.Conn]*Client
mutex sync.RWMutex
}
type Client struct {
ws *websocket.Conn
hid *hid.Hid
keyboard chan []byte
mouse chan []byte
lastHeartbeat time.Time
mutex sync.Mutex
}
type Message struct {
Type string `json:"type"`
Data string `json:"data"`
}

View File

@@ -1,117 +0,0 @@
package ws
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm/jiggler"
)
const (
KeyboardEvent int = 1
MouseEvent int = 2
)
type WsClient struct {
conn *websocket.Conn
hid *hid.Hid
keyboard chan []int
mouse chan []int
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (s *Service) Connect(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := &WsClient{
hid: hid.GetHid(),
conn: conn,
keyboard: make(chan []int, 200),
mouse: make(chan []int, 200),
}
go client.Start()
}
func (c *WsClient) Start() {
defer c.Clean()
c.hid.Open()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *WsClient) Read() error {
var zeroTime time.Time
_ = c.conn.SetReadDeadline(zeroTime)
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
return err
}
log.Debugf("received message: %s", message)
var event []int
err = json.Unmarshal(message, &event)
if err != nil {
log.Debugf("received invalid message: %s", message)
continue
}
if event[0] == KeyboardEvent {
c.keyboard <- event[1:]
} else if event[0] == MouseEvent {
c.mouse <- event[1:]
}
// update latest HID operation time
jiggler.GetJiggler().Update()
}
}
func (c *WsClient) Write(message []byte) error {
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.conn.WriteMessage(websocket.TextMessage, message)
}
func (c *WsClient) Clean() {
_ = c.conn.Close()
go clearQueue(c.keyboard)
close(c.keyboard)
go clearQueue(c.mouse)
close(c.mouse)
c.hid.Close()
log.Debug("websocket disconnected")
}
func clearQueue(queue chan []int) {
for range queue {
}
}

View File

@@ -1,20 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

32
web/eslint.config.js Normal file
View File

@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
{ ignores: ['dist', 'public/mockServiceWorker.js'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
eslintConfigPrettier
)

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"mocked": "vite --mode mocked",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --report-unused-disable-directives",
"preview": "vite preview"
},
"dependencies": {
@@ -31,7 +31,7 @@
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.27.0",
"react-router-dom": "^6.30.3",
"react-simple-keyboard": "^3.8.19",
"semver": "^7.6.3",
"vaul": "^0.9.9",
@@ -39,6 +39,7 @@
"yocto-queue": "^1.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
@@ -55,12 +56,14 @@
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^17.0.0",
"msw": "^2.6.0",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"typescript-eslint": "^8.53.0",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},
@@ -69,4 +72,4 @@
"public"
]
}
}
}

251
web/pnpm-lock.yaml generated
View File

@@ -69,8 +69,8 @@ importers:
specifier: ^10.0.0
version: 10.0.0(react@18.3.1)
react-router-dom:
specifier: ^6.27.0
version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^6.30.3
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-simple-keyboard:
specifier: ^3.8.19
version: 3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -87,6 +87,9 @@ importers:
specifier: ^1.2.1
version: 1.2.1
devDependencies:
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
'@ianvs/prettier-plugin-sort-imports':
specifier: ^4.7.0
version: 4.7.0(prettier@3.4.2)
@@ -135,6 +138,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.16
version: 0.4.16(eslint@9.39.1(jiti@1.21.7))
globals:
specifier: ^17.0.0
version: 17.0.0
msw:
specifier: ^2.6.0
version: 2.6.0(@types/node@22.9.0)(typescript@5.6.3)
@@ -153,6 +159,9 @@ importers:
typescript:
specifier: ^5.6.3
version: 5.6.3
typescript-eslint:
specifier: ^8.53.0
version: 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
vite:
specifier: 7.1.11
version: 7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0)
@@ -498,10 +507,20 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.21.1':
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -522,6 +541,10 @@ packages:
resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7':
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -959,8 +982,8 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
'@remix-run/router@1.20.0':
resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==}
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
@@ -1138,6 +1161,14 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/eslint-plugin@8.53.0':
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.53.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.48.0':
resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1145,22 +1176,45 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.53.0':
resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.48.0':
resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.53.0':
resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.48.0':
resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.53.0':
resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.48.0':
resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/tsconfig-utils@8.53.0':
resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.48.0':
resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1168,16 +1222,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.53.0':
resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.48.0':
resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.53.0':
resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.48.0':
resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.53.0':
resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.48.0':
resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1185,10 +1256,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.53.0':
resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.48.0':
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.53.0':
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1787,6 +1869,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@17.0.0:
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
engines: {node: '>=18'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@@ -2705,15 +2791,15 @@ packages:
peerDependencies:
react: '>=16.8.0'
react-router-dom@6.27.0:
resolution: {integrity: sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==}
react-router-dom@6.30.3:
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.27.0:
resolution: {integrity: sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==}
react-router@6.30.3:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
@@ -2814,6 +2900,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2941,6 +3032,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -2991,6 +3088,13 @@ packages:
typedarray-to-buffer@3.1.5:
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
typescript-eslint@8.53.0:
resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.6.3:
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'}
@@ -3498,8 +3602,15 @@ snapshots:
eslint: 9.39.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@1.21.7))':
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.1':
dependencies:
'@eslint/object-schema': 2.1.7
@@ -3532,6 +3643,8 @@ snapshots:
'@eslint/js@9.39.1': {}
'@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.4.1':
@@ -3933,7 +4046,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@remix-run/router@1.20.0': {}
'@remix-run/router@1.23.2': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -4078,6 +4191,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/type-utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0
eslint: 9.39.1(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.48.0
@@ -4090,6 +4219,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.48.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.6.3)
@@ -4099,15 +4240,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.53.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
'@typescript-eslint/types': 8.53.0
debug: 4.4.3
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/visitor-keys': 8.48.0
'@typescript-eslint/scope-manager@8.53.0':
dependencies:
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.53.0
'@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.6.3)':
dependencies:
typescript: 5.6.3
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.6.3)':
dependencies:
typescript: 5.6.3
'@typescript-eslint/type-utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/types': 8.48.0
@@ -4120,8 +4279,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.48.0': {}
'@typescript-eslint/types@8.53.0': {}
'@typescript-eslint/typescript-estree@8.48.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/project-service': 8.48.0(typescript@5.6.3)
@@ -4137,6 +4310,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.53.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/project-service': 8.53.0(typescript@5.6.3)
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
@@ -4148,11 +4336,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.53.0':
dependencies:
'@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@4.7.0(vite@7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0))':
dependencies:
'@babel/core': 7.28.5
@@ -4952,6 +5156,8 @@ snapshots:
globals@14.0.0: {}
globals@17.0.0: {}
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1
@@ -5833,16 +6039,16 @@ snapshots:
react: 18.3.1
shallow-equal: 3.1.0
react-router-dom@6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.20.0
'@remix-run/router': 1.23.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.27.0(react@18.3.1)
react-router: 6.30.3(react@18.3.1)
react-router@6.27.0(react@18.3.1):
react-router@6.30.3(react@18.3.1):
dependencies:
'@remix-run/router': 1.20.0
'@remix-run/router': 1.23.2
react: 18.3.1
react-simple-keyboard@3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -5969,6 +6175,8 @@ snapshots:
semver@7.6.3: {}
semver@7.7.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -6141,6 +6349,10 @@ snapshots:
dependencies:
typescript: 5.6.3
ts-api-utils@2.4.0(typescript@5.6.3):
dependencies:
typescript: 5.6.3
ts-interface-checker@0.1.13: {}
tsconfck@3.1.4(typescript@5.6.3):
@@ -6195,6 +6407,17 @@ snapshots:
dependencies:
is-typedarray: 1.0.0
typescript-eslint@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
typescript@5.6.3: {}
unbox-primitive@1.0.2:

View File

@@ -22,3 +22,34 @@ 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);
}
// get shortcut leader key
export function getLeaderKey() {
return http.get('/api/hid/shortcut/leader-key');
}
// set shortcut leader key
export function setLeaderKey(key: string) {
return http.post('/api/hid/shortcut/leader-key', { key });
}

View File

@@ -14,11 +14,10 @@ export function getWolMacs() {
}
export function deleteWolMac(mac: string) {
return http.request({
method: 'delete',
url: '/api/network/wol/mac',
data: { mac }
});
const data = {
mac
};
return http.delete('/api/network/wol/mac', data);
}
// set Mac name

View File

@@ -20,9 +20,8 @@ export function getScripts() {
}
export function deleteScript(name: string) {
return http.request({
url: '/api/vm/script',
method: 'delete',
data: { name }
});
const data = {
name
};
return http.delete('/api/vm/script', data);
}

View File

@@ -1,7 +1,10 @@
import { ReactNode, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { useSetAtom } from 'jotai';
import { useMediaQuery } from 'react-responsive';
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
type MenuItemProps = {
title: string;
icon: ReactNode;
@@ -20,6 +23,7 @@ export const MenuItem = ({
onOpenChange
}: MenuItemProps) => {
const isBigScreen = useMediaQuery({ minWidth: 640 });
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
@@ -28,6 +32,9 @@ export const MenuItem = ({
setIsTooltipOpen(false);
setIsPopoverOpen(open);
// Update global submenu count
setSubmenuOpenCount((count) => (open ? count + 1 : Math.max(0, count - 1)));
if (onOpenChange) {
onOpenChange(open);
}

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

@@ -0,0 +1,55 @@
import { RefObject, useCallback, useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export interface MenuBounds {
left: number;
right: number;
top: number;
bottom: number;
}
export function useMenuBounds(
nodeRef: RefObject<HTMLDivElement | null>,
isMenuExpanded: boolean
): MenuBounds {
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
left: 0,
right: 0,
top: 0,
bottom: 0
});
const handleResize = useCallback(() => {
if (!nodeRef.current) return;
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setMenuBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
}, [nodeRef]);
// Update bounds on mount and resize
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
// Update bounds when menu items or expansion state change
useEffect(() => {
handleResize();
}, [menuDisabledItems, isMenuExpanded, handleResize]);
return menuBounds;
}

View File

@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
import {
menuDisabledItemsAtom,
menuDisplayModeAtom,
submenuOpenCountAtom
} from '@/jotai/settings.ts';
const HIDE_TIMEOUT = 5000;
export interface MenuVisibilityState {
isInitialized: boolean;
isMenuExpanded: boolean;
isMenuHidden: boolean;
isMenuMoved: boolean;
handleHovered: (hovered: boolean) => void;
handleMoved: () => void;
setIsMenuExpanded: (expanded: boolean) => void;
}
export function useMenuVisibility(): MenuVisibilityState {
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
const [isMenuMoved, setIsMenuMoved] = useState(false);
const [isMenuHovered, setIsMenuHovered] = useState(false);
const [isMenuHidden, setIsMenuHidden] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startCountdown = useCallback(() => {
if (menuDisplayMode !== 'auto' || submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
return;
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsMenuHidden(true);
}, HIDE_TIMEOUT);
}, [menuDisplayMode, submenuOpenCount, isMenuExpanded, isMenuMoved]);
const stopCountdown = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Initialize menu settings
useEffect(() => {
const displayMode = getMenuDisplayMode();
setMenuDisplayMode(displayMode);
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
if (displayMode === 'off') {
setIsMenuExpanded(false);
}
setIsInitialized(true);
return () => {
stopCountdown();
};
}, []);
// Handle display mode changes
useEffect(() => {
setIsMenuHidden(false);
if (menuDisplayMode === 'auto') {
startCountdown();
} else {
stopCountdown();
}
}, [menuDisplayMode, startCountdown, stopCountdown]);
// Handle hover and submenu state
useEffect(() => {
if (submenuOpenCount === 0 && !isMenuHovered) {
startCountdown();
} else {
stopCountdown();
}
}, [submenuOpenCount, isMenuHovered, startCountdown, stopCountdown]);
// Handle hover state
const handleHovered = useCallback((hovered: boolean) => {
setIsMenuHovered(hovered);
if (hovered) {
setIsMenuHidden(false);
}
}, []);
const handleMoved = useCallback(() => {
if (!isMenuMoved) {
setIsMenuMoved(true);
}
}, [isMenuMoved]);
return {
isMenuExpanded,
isMenuHidden,
isMenuMoved,
isInitialized,
handleHovered,
handleMoved,
setIsMenuExpanded
};
}

View File

@@ -76,14 +76,51 @@ 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.'
},
leaderKey: {
title: 'Leader Key',
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
howToUse: 'How to Use',
simultaneous: {
title: 'Simultaneous Mode',
desc1: 'Press and hold the Leader Key, then press the shortcut.',
desc2: 'Intuitive, but may conflict with system shortcuts.'
},
sequential: {
title: 'Sequential Mode',
desc1:
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
desc2: 'Requires more steps, but completely avoids system conflicts.'
},
enable: 'Enable Leader Key',
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
placeholder: 'Please press the Leader Key',
shiftRight: 'Right Shift',
ctrlRight: 'Right Ctrl',
metaRight: 'Right Win',
submit: 'Submit',
recorder: {
rec: 'REC',
activate: 'Activate keys',
input: 'Please press the shortcut...'
}
}
},
mouse: {
title: 'Mouse',
@@ -234,10 +271,18 @@ const en = {
display: 'Display',
language: 'Language',
languageDesc: 'Select the language for the interface',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: 'Web Title',
webTitleDesc: 'Customize the web page title'
webTitleDesc: 'Customize the web page title',
menuBar: {
title: 'Menu Bar',
mode: 'Display Mode',
modeDesc: 'Display menu bar on the screen',
modeOff: 'Off',
modeAuto: 'Auto hide',
modeAlways: 'Always visible',
icons: 'Submenu Icons',
iconsDesc: 'Display submenu icons in the menu bar'
}
},
device: {
title: 'Device',

View File

@@ -217,10 +217,19 @@ const ko = {
title: '디자인',
display: '표시',
language: '언어',
menuBar: '메뉴 바',
menuBarDesc: '메뉴 바에 아이콘을 표시',
languageDesc: '인터페이스 언어를 선택하세요',
webTitle: '웹 제목',
webTitleDesc: '웹 페이지 제목 사용자 지정'
webTitleDesc: '웹 페이지 제목 사용자 지정',
menuBar: {
title: '메뉴 바',
mode: '표시 모드',
modeDesc: '메뉴 바를 화면에 표시합니다',
modeOff: '꺼짐',
modeAuto: '자동 숨기기',
modeAlways: '항상 보이기',
icons: '하위 메뉴 아이콘',
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
}
},
device: {
title: '장치',
@@ -336,7 +345,14 @@ const ko = {
cancel: '취소',
preview: '미리보기 업데이트',
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
offline: {
title: '오프라인 업데이트',
desc: '로컬 설치 패키지를 통한 업데이트',
upload: '업로드',
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
}
},
account: {
title: '계정',

View File

@@ -94,7 +94,7 @@ const tr = {
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
hidOnly: {
title: 'Sadece İnsan Arayüz Cihazı modu',
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-disk’i ve sanal ağı devre dışı bırakacaktır.',
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',

View File

@@ -73,7 +73,42 @@ const zh = {
placeholder: '请输入内容',
submit: '确定',
virtual: '虚拟键盘',
ctrlaltdel: 'Ctrl+Alt+Del'
shortcut: {
title: '快捷键',
custom: '自定义',
capture: '点击此处捕获快捷键',
clear: '清空',
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
},
leaderKey: {
title: '引导键',
desc: '绕过浏览器限制,向远程主机发送被系统拦截的快捷键。',
howToUse: '使用方法',
simultaneous: {
title: '组合按键模式',
desc1: '按住引导键不放,同时按下目标快捷键。',
desc2: '操作直观,但少数快捷键可能因系统占用而无法生效。'
},
sequential: {
title: '序列输入模式',
desc1: '点击【引导键】开始 → 依次点击【快捷键】→ 点击【引导键】结束。',
desc2: '步骤较多,但能完美避开系统键位冲突。'
},
enable: '启用引导键',
tip: '设为引导键后,该按键将仅用于触发快捷键,不再作为普通按键使用。',
placeholder: '请按下引导键',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '确定',
recorder: {
rec: '录制中',
activate: '激活按键',
input: '请按下快捷键...'
}
}
},
mouse: {
title: '鼠标',
@@ -204,10 +239,18 @@ const zh = {
display: '显示',
language: '语言',
languageDesc: '选择界面语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: '网站标题',
webTitleDesc: '自定义网站页面标题'
webTitle: '网页标题',
webTitleDesc: '自定义网站页面标题',
menuBar: {
title: '菜单栏',
mode: '显示方式',
modeDesc: '菜单栏在屏幕上的显示方式',
modeOff: '关闭',
modeAuto: '自动隐藏',
modeAlways: '始终显示',
icons: '菜单图标',
iconsDesc: '是否在菜单栏中显示子菜单图标'
}
},
device: {
title: '设备',

View File

@@ -14,7 +14,7 @@ const zh_tw = {
placeholderPassword2: '請再次輸入密碼',
noEmptyUsername: '使用者名稱不能為空',
noEmptyPassword: '密碼不能為空',
noAccount: '取得使用者資訊失敗,請重新整理網頁或重設密碼',
noAccount: '找不到使用者,請重新整理網頁或重設密碼',
invalidUser: '使用者名稱或密碼錯誤',
error: '非預期性錯誤',
changePassword: '更改密碼',
@@ -33,26 +33,28 @@ const zh_tw = {
reset4: 'SSH 預設帳號:',
change1: '請注意,此操作將同時更新下列密碼:',
change2: '網頁登入密碼',
change3: 'root user密碼 (SSH登入密碼)',
change3: 'root user 密碼 (SSH登入密碼)',
change4: '如果您忘記密碼,需要長按 NanoKVM 上的 BOOT 按鍵來重設密碼。'
}
},
wifi: {
title: 'Wi-Fi',
description: '設定 NanoKVM WiFi',
description: '設定 NanoKVM Wi-Fi',
success: '請檢查 NanoKVM 的網路狀態,並存取新的 IP 位址。',
failed: '操作失敗,請重試。',
invalidMode: '目前模式不支援設定網路。請先前往裝置啟用 Wi-Fi 配置模式。',
confirmBtn: '確定',
finishBtn: '完成'
},
screen: {
scale: '缩放',
title: '螢幕',
video: '編碼格式',
videoDirectTips: '本模式需先啟用HTTPS,請前往「設定 -> 設備」中開啟',
videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟',
resolution: '解析度',
auto: '自動',
autoTips:
'在特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。考慮調整遠端主機的解析度或停用自動模式。',
'在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。',
fps: '更新頻率',
customizeFps: '自定義',
quality: '品質',
@@ -61,17 +63,26 @@ const zh_tw = {
qualityMedium: '中',
qualityLow: '低',
frameDetect: '影格檢測',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止傳輸視訊串流。',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
resetHdmi: '重置 HDMI'
},
keyboard: {
title: 'Keyboard',
title: '鍵盤',
paste: '貼上',
tips: '僅支援標準鍵盤字母和符號',
tips: '僅支援標準鍵盤的字母和符號',
placeholder: '請輸入內容',
submit: '送出',
virtual: '虛擬鍵盤',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
shortcut: {
title: '快捷鍵',
custom: '自定義',
capture: '點選此處錄製快捷鍵',
clear: '清空',
save: '儲存',
captureTips: '錄製系統級按鍵(如 Windows 鍵)需要全螢幕的權限。',
enterFullScreen: '切換全螢幕模式。'
}
},
mouse: {
title: '滑鼠',
@@ -85,53 +96,63 @@ const zh_tw = {
mode: '滑鼠模式',
absolute: '絕對模式',
relative: '相對模式',
direction: '滾輪方向',
scrollUp: '向上',
scrollDown: '向下',
speed: '滾輪速度',
fast: '快',
slow: '慢',
requestPointer: '使用相對模式。請按一下桌面以取得滑鼠游標。',
requestPointer: '正在使用滑鼠相對模式。請按一下桌面以取得滑鼠游標。',
resetHid: '重設 HID',
hidOnly: {
title: 'HID-Only 模式',
desc: "如果您的滑鼠和鍵盤停止回應,且重設 HID 無效,可能是 NanoKVM 與裝置間的相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會卸載虛擬隨身碟和虛擬網卡',
tip2: '在 HID-Only 模式下,映像掛載功能將被停用',
desc: "如果您的滑鼠和鍵盤沒有反應,且重設 HID 無效,可能是 NanoKVM 與您的裝置間有相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會停用虛擬隨身碟和虛擬網卡的功能',
tip2: '在 HID-Only 模式下,映像檔掛載功能將被停用',
tip3: 'NanoKVM 將在切換模式後自動重新啟動',
enable: '啟用 HID-Only 模式',
disable: '停用 HID-Only 模式'
}
},
image: {
title: '映像',
title: '映像檔',
loading: '載入中...',
empty: '未找到任何內容',
cdrom: '以CD-ROM模式掛載',
mountMode: '掛載模式',
mountFailed: '掛載失敗',
mountDesc: '在某些系統中,在掛載映像之前需要中斷遠端主機上的虛擬磁碟。',
refresh: 'Refresh the image list',
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
unmountFailed: '解除安裝失敗',
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
refresh: '重新整理映像檔列表',
attention: '注意',
deleteConfirm: '確定要刪除該映像檔嗎?',
okBtn: '確定',
cancelBtn: '取消',
tips: {
title: '如何上傳',
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
usb2: '確保已安裝虛擬磁碟(設定 - 虛擬磁碟)。',
usb3: '開啟電腦上的虛擬磁碟,將映像檔案複製到虛擬磁碟的根目錄下。',
usb3: '開啟電腦上的虛擬磁碟,將映象檔案複製到虛擬磁碟的根目錄下。',
scp1: '確保 NanoKVM 和您的電腦位於同一區域網路。',
scp2: '開啟電腦上的終端機,使用 SCP 指令將映像檔案上傳到 NanoKVM 的 /data 目錄下。',
scp3: '範例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'microSD 卡',
tf1: '此方法適用於 Linux 系統',
tf2: '從 NanoKVM 取得 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '將 microSD 卡插入讀卡機並連接至電腦。',
tf4: '將映像檔案複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插入 NanoKVM。'
tf2: '從 NanoKVM 拔出 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '使用 USB 讀卡機將 microSD 卡連接至電腦。',
tf4: '將映像檔複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插回 NanoKVM。'
}
},
script: {
title: '腳本',
title: '指令碼',
upload: '上傳',
run: '執行',
runBackground: '背景執行',
runFailed: '執行失敗',
attention: '注意',
delDesc: '確定刪除該檔案?',
delDesc: '確定要刪除該檔案嗎?',
confirm: '確定',
cancel: '取消',
delete: '刪除',
@@ -164,22 +185,22 @@ const zh_tw = {
ok: '確定'
},
download: {
title: '下载映像',
input: '請輸入映像檔的下載URL',
title: '下载映像檔',
input: '請輸入映像檔的下載 URL',
ok: '確定',
disabled: '/data 為唯讀目錄,無法下載映像檔'
},
power: {
title: '電源',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
title: '電源控制',
showConfirm: '顯示確認框',
showConfirmTip: '電源操作需要二次確認',
reset: '重新啟動',
power: '電源',
powerShort: '電源 (短按)',
powerLong: '電源 (長按)',
resetConfirm: '執行重新啟動按鍵操作?',
powerConfirm: '執行電源按鍵操作?',
okBtn: '是',
resetConfirm: '確認執行重新啟動的按鍵操作嗎?',
powerConfirm: '確認執行電源的按鍵操作嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
settings: {
@@ -191,8 +212,8 @@ const zh_tw = {
mdns: 'mDNS',
application: '應用程式版本',
applicationTip: 'NanoKVM 網頁程式版本',
image: '映像版本',
imageTip: 'NanoKVM 系统镜像版本',
image: '韌體版本',
imageTip: 'NanoKVM 系统韌體版本',
deviceKey: '設備序號',
community: '社群',
hostname: '主機名稱',
@@ -207,10 +228,20 @@ const zh_tw = {
title: '外觀',
display: '顯示',
language: '語言',
menuBar: '選單列',
languageDesc: '選擇介面語言',
menuBarDesc: '是否在選單列中顯示圖案',
webTitle: '網頁標題',
webTitleDesc: '自訂網頁標題'
webTitleDesc: '自訂網頁標題',
menuBar: {
title: '選單列',
mode: '顯示方式',
modeDesc: '選單欄在螢幕上的顯示方式',
modeOff: '關閉',
modeAuto: '自動隱藏',
modeAlways: '始終顯示',
icons: '選單圖示',
iconsDesc: '是否在選單欄中顯示子選單圖示'
}
},
device: {
title: '設備',
@@ -218,57 +249,80 @@ const zh_tw = {
title: 'OLED',
description: '設定 OLED 螢幕自動睡眠時間',
0: '永不',
15: '15秒',
30: '30秒',
60: '1分鐘',
180: '3分鐘',
300: '5分鐘',
600: '10分鐘',
1800: '30分鐘',
3600: '1小時'
15: '15 秒',
30: '30 秒',
60: '1 分鐘',
180: '3 分鐘',
300: '5 分鐘',
600: '10 分鐘',
1800: '30 分鐘',
3600: '1 小時'
},
wifi: {
title: 'Wi-Fi',
description: '設定 Wi-Fi',
setBtn: '設定'
setBtn: '設定',
apMode: 'AP 模式已啟用,該模式下僅支援掃描 QRCode 配網',
connect: '連線 Wi-Fi',
connectDesc1: '請輸入網路名稱和密碼',
connectDesc2: '請輸入密碼以連線此網路',
disconnect: '是否要中斷該網路連線?',
failed: '連線失敗,請重試',
ssid: 'SSID 名稱',
password: '密碼',
joinBtn: '加入',
confirmBtn: '確定',
cancelBtn: '取消'
},
ssh: {
description: '啟用SSH',
description: '啟用 SSH 伺服器',
tip: '啟用前請務必設定強密碼(帳號 - 更改密碼)'
},
tls: {
description: '啟用 HTTPS',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加延遲,特別是使用 MJPEG 編碼格式時。'
description: '啟用 HTTPS 協議',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加傳輸延遲,特別是使用 MJPEG 格式傳輸時。'
},
advanced: '進階設定',
swap: {
title: 'Swap',
disable: '停用',
description: '設定Swap檔大小',
description: '設定 Swap 檔大小',
tip: "啟用此功能可能會減少SD卡的使用壽命!"
},
mouseJiggler: {
title: 'Mouse Jiggler',
title: '滑鼠抖動模式 (Mouse Jiggler)',
description: '避免遠端主機進入休眠狀態',
disable: '停用',
absolute: '絕對模式',
relative: '相對模式'
},
mdns: {
description: '啟用mDNS服務',
description: '啟用 mDNS 發現服務',
tip: "若無需求,建議關閉此功能"
},
hdmi: {
description: '啟用 HDMI/螢幕 輸出'
},
autostart: {
title: '啟動時指令碼設定',
description: '管理能夠在 NanoKVM 啟動時自動執行的相關指令碼',
new: '建立新指令碼',
deleteConfirm: '確定要刪除該檔案嗎?',
yes: '是',
no: '否',
scriptName: 'Script 名稱',
scriptContent: 'Script 內容',
settings: '設定'
},
hidOnly: 'HID-Only 模式',
hidOnlyDesc: '該模式下不再打開虛擬隨身碟,僅保留基礎的 鍵盤/滑鼠 的控制功能。',
disk: '虛擬隨身碟',
diskDesc: '在遠端主機上連接虛擬隨身碟',
network: '虛擬網卡',
networkDesc: '在遠端主機上新增虛擬網卡',
reboot: '重新啟動',
rebootDesc: '你確定要重新啟動 NanoKVM?',
okBtn: '是',
rebootDesc: '您確定要重新啟動 NanoKVM?',
okBtn: '確定',
cancelBtn: '取消'
},
tailscale: {
@@ -278,6 +332,10 @@ const zh_tw = {
tip: '當記憶體使用量超過限制時,會更積極的進行垃圾回收來嘗試釋放記憶體。若使用 Tailscale 建議設定為 50MB,於重啟 Tailscale 後生效。',
disable: '關閉'
},
swap: {
title: 'Swap',
tip: '如果啟用記憶體最佳化後依然存在問題,可以嘗試開啟 Swap。啟用後會將交換檔案設定為 256MB,可以在「設定 - 裝置」中修改該選項。'
},
restart: '確定要重啟 Tailscale 嗎?',
stop: '確定要停止 Tailscale 嗎?',
stopDesc: '此操作將會登出帳號,並停止開機自動啟動。',
@@ -290,9 +348,11 @@ const zh_tw = {
download: '下載',
package: '安裝包',
unzip: '並解壓縮它',
upTailscale: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/bin/',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/sbin/',
upTailscale: '將 Tailscale 上傳到 NanoKVM 的 /usr/bin/ 資料夾',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 的 /usr/sbin/ 資料夾',
refresh: '重新整理頁面',
notRunning: 'Tailscale 尚未執行',
run: '啟動',
notLogin: '設備尚未綁定。請登入並將該裝置綁定到您的帳戶。',
urlPeriod: '此網址有效期限為 10 分鐘',
login: '登入',
@@ -302,24 +362,33 @@ const zh_tw = {
deviceIP: '裝置 IP',
account: '帳號',
logout: '登出',
logoutDesc: '確認登出?',
logoutDesc: '確認要登出嗎?',
uninstall: '移除 Tailscale',
uninstallDesc: '確定要解除安裝 Tailscale 嗎?',
reboot: '重啟',
rebootDesc: '您確定要重啟 NanoKVM 嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
update: {
title: '檢查更新',
queryFailed: '取得版本失敗',
queryFailed: '取得版本號失敗',
updateFailed: '更新失敗。請重試。',
isLatest: '您已經擁有最新版本。',
available: '有可用更新。確定要更新嗎?',
updating: '正在更新。請稍等...',
isLatest: '您已經是最新版本。',
available: '發現有可用更新。您確定要更新嗎?',
updating: '更新中,請稍候...',
confirm: '確定',
cancel: '取消',
preview: '預覽更新',
previewDesc: '搶先體驗新功能和改進',
previewTip:
'請注意,預覽版本可能包含錯誤或功能不完整!'
previewDesc: '預覽版本,搶先體驗新功能和改進',
previewTip: '請注意,預覽版本可能包含一些不穩定因素或未完善的功能!',
offline: {
title: '離線更新',
desc: '透過本地安裝包進行更新',
upload: '上傳',
invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。',
updateFailed: '更新失敗,請重試'
}
},
account: {
title: '帳號',
@@ -327,9 +396,9 @@ const zh_tw = {
password: '密碼',
updateBtn: '修改',
logoutBtn: '登出',
logoutDesc: '你確定要登出嗎?',
okBtn: '是',
cancelBtn: '取消'
logoutDesc: '您確定要登出嗎?',
confirm: '確定',
cancel: '取消'
}
},
error: {

View File

@@ -5,3 +5,6 @@ export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);
// leader key code for bypassing browser shortcuts (empty string means disabled)
export const leaderKeyAtom = atom('');

View File

@@ -3,5 +3,11 @@ import { atom } from 'jotai';
// menu bar disabled items
export const menuDisabledItemsAtom = atom<string[]>([]);
// track how many submenus are currently open
export const submenuOpenCountAtom = atom(0);
// web title
export const webTitleAtom = atom('');
// menu display mode: 'off' | 'auto' | 'always'
export const menuDisplayModeAtom = atom<string>('auto');

61
web/src/lib/browser.ts Normal file
View File

@@ -0,0 +1,61 @@
export type System = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
export interface BrowserInfo {
os: System;
isChrome: boolean;
}
export function getOperatingSystem(): System {
if (typeof window === 'undefined') {
return 'Unknown';
}
if ('userAgentData' in navigator) {
// @ts-expect-error check userAgentData.platform
const platform = navigator.userAgentData?.platform?.toLowerCase();
if (platform) {
if (platform === 'windows') return 'Windows';
if (platform === 'macos') return 'macOS';
if (platform === 'linux' || platform === 'android') return 'Linux';
}
}
// Fallback to User Agent
const userAgent = navigator.userAgent;
if (/Win/i.test(userAgent)) return 'Windows';
if (/Mac|iPhone|iPod|iPad/i.test(userAgent)) return 'macOS';
if (/Linux|Android/i.test(userAgent)) return 'Linux';
return 'Unknown';
}
export function isChromeBrowser(): boolean {
if (typeof window === 'undefined' || !window.navigator) {
return false;
}
if ('userAgentData' in navigator) {
// @ts-expect-error userAgentData.brands
return navigator.userAgentData?.brands?.some(
(brand: any) => brand.brand === 'Google Chrome' || brand.brand === 'Chromium'
);
}
const userAgent = navigator.userAgent;
const vendor = navigator.vendor;
return (
/Chrome|Chromium/.test(userAgent) &&
/Google Inc/.test(vendor) &&
!/Edg/.test(userAgent) &&
!/OPR/.test(userAgent)
);
}
export function getBrowserInfo(): BrowserInfo {
return {
os: getOperatingSystem(),
isChrome: isChromeBrowser()
};
}

View File

@@ -66,6 +66,14 @@ class Http {
});
}
public delete(url: string, data?: any): Promise<Response> {
return this.instance.request({
method: 'delete',
url,
data
});
}
public request(config: AxiosRequestConfig): Promise<Response> {
return this.instance.request(config);
}

65
web/src/lib/keyboard.ts Normal file
View File

@@ -0,0 +1,65 @@
import { getKeycode, getModifierBit, isModifier } from './keymap';
const MAX_KEYS = 6;
export class KeyboardReport {
private modifier: number = 0;
private pressedKeys: Map<string, number> = new Map();
keyDown(code: string): Uint8Array {
if (isModifier(code)) {
this.modifier |= getModifierBit(code);
} else {
const keycode = getKeycode(code);
if (keycode !== undefined && this.pressedKeys.size < MAX_KEYS) {
this.pressedKeys.set(code, keycode);
}
}
return this.buildReport();
}
keyUp(code: string): Uint8Array {
if (isModifier(code)) {
this.modifier &= ~getModifierBit(code);
} else {
this.pressedKeys.delete(code);
}
return this.buildReport();
}
reset(): Uint8Array {
this.modifier = 0;
this.pressedKeys.clear();
return this.buildReport();
}
/**
* Build the 8-byte HID keyboard report
* Byte 0: Modifier keys bitmap
* Byte 1: Reserved (0x00)
* Bytes 2-7: Up to 6 keycodes
*/
private buildReport(): Uint8Array {
const report = new Uint8Array(8);
report[0] = this.modifier;
report[1] = 0x00;
let i = 2;
for (const keycode of this.pressedKeys.values()) {
if (i >= 8) break;
report[i++] = keycode;
}
return report;
}
getModifier(): number {
return this.modifier;
}
getPressedKeyCount(): number {
return this.pressedKeys.size;
}
}
export const keyboard = new KeyboardReport();

269
web/src/lib/keymap.ts Normal file
View File

@@ -0,0 +1,269 @@
// Modifier key bit positions
export const ModifierBits = {
LeftCtrl: 1 << 0,
LeftShift: 1 << 1,
LeftAlt: 1 << 2,
LeftMeta: 1 << 3,
RightCtrl: 1 << 4,
RightShift: 1 << 5,
RightAlt: 1 << 6,
RightMeta: 1 << 7
} as const;
// Map event.code to HID modifier bit
export const ModifierMap: Record<string, number> = {
ControlLeft: ModifierBits.LeftCtrl,
ShiftLeft: ModifierBits.LeftShift,
AltLeft: ModifierBits.LeftAlt,
MetaLeft: ModifierBits.LeftMeta,
ControlRight: ModifierBits.RightCtrl,
ShiftRight: ModifierBits.RightShift,
AltRight: ModifierBits.RightAlt,
MetaRight: ModifierBits.RightMeta
};
// Map event.code to HID keycode
export const KeycodeMap: Record<string, number> = {
// Letters
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
// Numbers
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
// Special keys
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
CapsLock: 0x39,
// Function keys
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
// Control keys
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
// Arrow keys
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
// Numpad
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
// International / Non-US keyboard keys
IntlBackslash: 0x64,
ContextMenu: 0x65,
Power: 0x66,
NumpadEqual: 0x67,
// Extended function keys
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
F17: 0x6c,
F18: 0x6d,
F19: 0x6e,
F20: 0x6f,
F21: 0x70,
F22: 0x71,
F23: 0x72,
F24: 0x73,
// System / Edit keys
Execute: 0x74,
Help: 0x75,
Props: 0x76,
Select: 0x77,
Stop: 0x78,
Again: 0x79,
Undo: 0x7a,
Cut: 0x7b,
Copy: 0x7c,
Paste: 0x7d,
Find: 0x7e,
// Media / Volume keys
AudioVolumeMute: 0x7f,
AudioVolumeUp: 0x80,
AudioVolumeDown: 0x81,
VolumeMute: 0x7f, // Alias
VolumeUp: 0x80, // Alias
VolumeDown: 0x81, // Alias
// Locking keys (for keyboards with physical lock keys)
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
// Numpad additional
NumpadComma: 0x85,
NumpadEqual2: 0x86, // AS/400 keyboard equal key
// International keys - Japanese
IntlRo: 0x87, // Japanese Ro key (ろ)
KanaMode: 0x88, // Katakana/Hiragana toggle
IntlYen: 0x89, // Japanese Yen (¥)
Convert: 0x8a, // Japanese Henkan (変換)
NonConvert: 0x8b, // Japanese Muhenkan (無変換)
// International keys - Additional Japanese
International6: 0x8c,
International7: 0x8d,
International8: 0x8e,
International9: 0x8f,
// Language keys - Korean/Japanese/Chinese
Lang1: 0x90, // Korean Hangul/English toggle
Lang2: 0x91, // Korean Hanja
Lang3: 0x92, // Japanese Katakana
Lang4: 0x93, // Japanese Hiragana
Lang5: 0x94, // Japanese Zenkaku/Hankaku
Lang6: 0x95,
Lang7: 0x96,
Lang8: 0x97,
Lang9: 0x98,
// ISO keyboard specific
IntlHash: 0x32, // Non-US # and ~ (ISO keyboards)
// Numpad extended
NumpadParenLeft: 0xb6,
NumpadParenRight: 0xb7,
NumpadBackspace: 0xbb,
NumpadMemoryStore: 0xd0,
NumpadMemoryRecall: 0xd1,
NumpadMemoryClear: 0xd2,
NumpadMemoryAdd: 0xd3,
NumpadMemorySubtract: 0xd4,
NumpadClear: 0xd8,
NumpadClearEntry: 0xd9,
// Additional browser/system keys
BrowserSearch: 0xf0,
BrowserHome: 0xf1,
BrowserBack: 0xf2,
BrowserForward: 0xf3,
BrowserStop: 0xf4,
BrowserRefresh: 0xf5,
BrowserFavorites: 0xf6,
// Media keys
MediaPlayPause: 0xe8,
MediaStop: 0xe9,
MediaTrackPrevious: 0xea,
MediaTrackNext: 0xeb,
Eject: 0xec,
MediaSelect: 0xed,
// Application launch keys
LaunchMail: 0xee,
LaunchApp1: 0xef,
LaunchApp2: 0xf0,
// Sleep/Wake keys
Sleep: 0xf8,
Wake: 0xf9,
// Accessibility keys
MediaRewind: 0xfa,
MediaFastForward: 0xfb
};
// Check if code is a modifier key
export function isModifier(code: string): boolean {
return code in ModifierMap;
}
// Get modifier bit for code
export function getModifierBit(code: string): number {
return ModifierMap[code] ?? 0;
}
// Get keycode for code
export function getKeycode(code: string): number | undefined {
return KeycodeMap[code];
}

View File

@@ -17,6 +17,7 @@ const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
type ItemWithExpiry = {
@@ -212,6 +213,15 @@ export function getMenuDisabledItems(): string[] {
return value ? JSON.parse(value) : [];
}
export function getMenuDisplayMode(): string {
const value = localStorage.getItem(MENU_AUTO_HIDE_KEY);
return value || 'auto';
}
export function setMenuDisplayMode(mode: string) {
localStorage.setItem(MENU_AUTO_HIDE_KEY, mode);
}
export function getPowerConfirm() {
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
return enabled === 'true';

138
web/src/lib/mouse.ts Normal file
View File

@@ -0,0 +1,138 @@
// Button bit positions
const MouseButtons = {
Left: 1 << 0,
Right: 1 << 1,
Middle: 1 << 2,
Back: 1 << 3,
Forward: 1 << 4
} as const;
// Map browser button index to HID bit
function getMouseButtonBit(button: number): number {
switch (button) {
case 0:
return MouseButtons.Left;
case 1:
return MouseButtons.Middle;
case 2:
return MouseButtons.Right;
case 3:
return MouseButtons.Back;
case 4:
return MouseButtons.Forward;
default:
return 0;
}
}
/**
* Relative Mouse Report (4 bytes)
* Used with /dev/hidg1 (relative mouse)
*
* Byte 0: Buttons
* Byte 1: X movement (-127 to 127)
* Byte 2: Y movement (-127 to 127)
* Byte 3: Wheel (-127 to 127)
*/
export class MouseReportRelative {
private buttons: number = 0;
buttonDown(button: number): void {
this.buttons |= getMouseButtonBit(button);
}
buttonUp(button: number): void {
this.buttons &= ~getMouseButtonBit(button);
}
/**
* Build relative mouse report
* @param deltaX X movement (-127 to 127)
* @param deltaY Y movement (-127 to 127)
* @param wheel Scroll wheel (-127 to 127, negative = down)
*/
buildReport(deltaX: number, deltaY: number, wheel: number = 0): Uint8Array {
const report = new Uint8Array(4);
report[0] = this.buttons;
report[1] = this.clamp(Math.round(deltaX), -127, 127) & 0xff;
report[2] = this.clamp(Math.round(deltaY), -127, 127) & 0xff;
report[3] = this.clamp(Math.round(wheel), -127, 127) & 0xff;
return report;
}
/**
* Build button-only report (no movement)
*/
buildButtonReport(): Uint8Array {
return this.buildReport(0, 0, 0);
}
reset(): Uint8Array {
this.buttons = 0;
return this.buildReport(0, 0, 0);
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}
/**
* Absolute Mouse Report (6 bytes)
* Used with /dev/hidg2 (absolute mouse/tablet)
*
* Byte 0: Buttons
* Byte 1-2: X position (0 to 32767, Little Endian)
* Byte 3-4: Y position (0 to 32767, Little Endian)
* Byte 5: Wheel
*/
export class MouseReportAbsolute {
private buttons: number = 0;
buttonDown(button: number): void {
this.buttons |= getMouseButtonBit(button);
}
buttonUp(button: number): void {
this.buttons &= ~getMouseButtonBit(button);
}
/**
* Build absolute mouse report
* @param x X position (0.0 to 1.0, normalized)
* @param y Y position (0.0 to 1.0, normalized)
* @param wheel Scroll wheel (-127 to 127)
*/
buildReport(x: number, y: number, wheel: number = 0): Uint8Array {
const report = new Uint8Array(6);
report[0] = this.buttons;
report[1] = x & 0xff;
report[2] = (x >> 8) & 0xff;
report[3] = y & 0xff;
report[4] = (y >> 8) & 0xff;
report[5] = this.clamp(Math.round(wheel), -127, 127) & 0xff;
return report;
}
/**
* Build button-only report (keeps last position)
*/
buildButtonReport(lastX: number, lastY: number): Uint8Array {
return this.buildReport(lastX, lastY, 0);
}
reset(): Uint8Array {
this.buttons = 0;
return this.buildReport(0, 0, 0);
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}
// Singleton instances
export const mouseRelative = new MouseReportRelative();
export const mouseAbsolute = new MouseReportAbsolute();

View File

@@ -2,64 +2,188 @@ import { IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket';
import { getBaseUrl } from '@/lib/service.ts';
type Event = (message: IMessageEvent) => void;
type MessageHandler = (message: IMessageEvent) => void;
type SendData = number[] | ArrayBuffer | Uint8Array;
const eventMap: Map<string, Event> = new Map<string, Event>();
export enum MessageEvent {
Heartbeat = 0,
Keyboard = 1,
Mouse = 2
}
class WsClient {
private readonly url: string;
private instance: W3cWebSocket;
interface WsClientOptions {
url?: string;
heartbeatInterval?: number;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
constructor() {
this.url = `${getBaseUrl('ws')}/api/ws`;
this.instance = new W3cWebSocket(this.url);
this.setEvents();
const DEFAULT_OPTIONS: Required<WsClientOptions> = {
url: `${getBaseUrl('ws')}/api/ws`,
heartbeatInterval: 10 * 1000,
reconnectInterval: 3 * 1000,
maxReconnectAttempts: 1
};
export class WsClient {
private readonly options: Required<WsClientOptions>;
private instance: W3cWebSocket | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private shouldReconnect = true;
private readonly eventHandlers = new Map<string, Set<MessageHandler>>();
constructor(options: WsClientOptions = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
}
public connect() {
this.close();
this.instance = new W3cWebSocket(this.url);
this.setEvents();
public connect(): void {
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.createConnection();
}
public send(data: number[]) {
if (this.instance.readyState !== W3cWebSocket.OPEN) {
public close(): void {
this.shouldReconnect = false;
this.cleanup();
if (this.instance && this.instance.readyState === W3cWebSocket.OPEN) {
this.instance.close();
}
this.instance = null;
}
public on(type: string, handler: MessageHandler): () => void {
if (!this.eventHandlers.has(type)) {
this.eventHandlers.set(type, new Set());
}
this.eventHandlers.get(type)!.add(handler);
return () => {
const handlers = this.eventHandlers.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.eventHandlers.delete(type);
}
}
};
}
public off(type: string, handler?: MessageHandler): void {
if (handler) {
const handlers = this.eventHandlers.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.eventHandlers.delete(type);
}
}
} else {
this.eventHandlers.delete(type);
}
}
public send(data: SendData): boolean {
if (!this.instance || !this.isConnected) {
return false;
}
if (data instanceof ArrayBuffer || (data as unknown) instanceof Uint8Array) {
this.instance.send(data);
} else {
this.instance.send(JSON.stringify(data));
}
return true;
}
public get isConnected(): boolean {
return this.instance?.readyState === W3cWebSocket.OPEN;
}
private createConnection(): void {
this.cleanup();
this.instance = new W3cWebSocket(this.options.url);
this.instance.binaryType = 'arraybuffer';
this.instance.onopen = this.handleOpen.bind(this);
this.instance.onclose = this.handleClose.bind(this);
this.instance.onerror = this.handleError.bind(this);
this.instance.onmessage = this.handleMessage.bind(this);
}
private handleOpen(): void {
this.reconnectAttempts = 0;
this.startHeartbeat();
}
private handleClose(): void {
this.stopHeartbeat();
this.scheduleReconnect();
}
private handleError(error: Error): void {
console.error('[WebSocket] Error:', error);
}
private handleMessage(message: IMessageEvent): void {
try {
const data = JSON.parse(message.data as string);
const handlers = this.eventHandlers.get(data.type);
if (handlers) {
handlers.forEach((handler) => handler(message));
}
} catch (err) {
console.log(err);
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.send(new Uint8Array([MessageEvent.Heartbeat]));
}, this.options.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private scheduleReconnect(): void {
if (!this.shouldReconnect) {
return;
}
const message = JSON.stringify(data);
this.instance.send(message);
}
public close() {
if (this.instance.readyState === W3cWebSocket.OPEN) {
this.instance.close();
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
console.error('[WebSocket] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
console.log(`[WebSocket] Reconnecting... (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.createConnection();
}, this.options.reconnectInterval);
}
public register(type: string, fn: (message: IMessageEvent) => void) {
eventMap.set(type, fn);
private cleanup(): void {
this.stopHeartbeat();
this.setEvents();
}
public unregister(type: string) {
eventMap.delete(type);
this.setEvents();
}
private setEvents() {
this.instance.onmessage = (message) => {
const data = JSON.parse(message.data as string);
if (!data) return;
const fn = eventMap.get(data.type);
if (!fn) return;
fn(message);
};
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}

View File

@@ -1,20 +1,19 @@
import { useEffect } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
import { Menu } from './menu';
import { Mouse } from './mouse';
import { Notification } from './notification.tsx';
import { Screen } from './screen';
import { VirtualKeyboard } from './virtual-keyboard';
export const Desktop = () => {
const { t } = useTranslation();
@@ -22,22 +21,17 @@ export const Desktop = () => {
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
useEffect(() => {
client.connect();
const mode = getVideoMode();
setVideoMode(mode);
const res = storage.getResolution() || { width: 0, height: 0 };
setResolution(res);
const timer = setInterval(() => {
client.send([0]);
}, 60 * 1000);
return () => {
clearInterval(timer);
client.unregister('stream');
client.close();
};
}, []);
@@ -67,7 +61,7 @@ export const Desktop = () => {
<Menu />
<Screen />
<Mouse />
{isKeyboardEnable && <Keyboard />}
<Keyboard />
</>
)}

View File

@@ -1,103 +1,164 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { client } from '@/lib/websocket.ts';
import { getOperatingSystem } from '@/lib/browser.ts';
import { KeyboardReport } from '@/lib/keyboard.ts';
import { isModifier } from '@/lib/keymap';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { KeyboardCodes } from './mappings.ts';
import { Recorder } from './recorder.tsx';
import { useAltGr } from './useAltGr.ts';
import { useLeaderKey } from './useLeaderKey.ts';
import { normalizeKeyCode } from './utils.ts';
export const Keyboard = () => {
const pressedKeys = useRef<Set<string>>(new Set());
const os = getOperatingSystem();
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const isComposing = useRef(false);
// Send key event helper
const sendKeyEvent = (type: 'keydown' | 'keyup', code: string) => {
const kb = keyboardRef.current;
const report = type === 'keydown' ? kb.keyDown(code) : kb.keyUp(code);
sendReport(report);
};
// Init leader key handler
const leaderKey = useLeaderKey(pressedKeys, sendKeyEvent);
const leaderKeyRef = useRef(leaderKey);
leaderKeyRef.current = leaderKey;
// Init AltGr key handler
const altGr = useAltGr(os, pressedKeys, sendKeyEvent);
const altGrRef = useRef(altGr);
altGrRef.current = altGr;
// listen keyboard events
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', releaseAllKeys);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
document.addEventListener('compositionstart', handleCompositionStart);
document.addEventListener('compositionend', handleCompositionEnd);
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
// press button
function handleKeyDown(event: KeyboardEvent) {
disableEvent(event);
if (!isKeyboardEnabled) return;
if (isComposing.current || event.isComposing) return;
if (!pressedKeys.current.has(event.code)) {
pressedKeys.current.add(event.code);
}
event.preventDefault();
event.stopPropagation();
sendKeyDown(event);
}
const code = normalizeKeyCode(event, os);
if (!code || pressedKeys.current.has(code)) return;
// release button
function handleKeyUp(event: KeyboardEvent) {
disableEvent(event);
if (pressedKeys.current.has(event.code)) {
pressedKeys.current.delete(event.code);
}
sendKeyUp();
}
function releaseAllKeys() {
if (pressedKeys.current.size === 0) {
// Handle leader key
const leaderHandled = leaderKeyRef.current.handleKeyDown(code);
if (leaderHandled) {
return;
}
sendKeyUp();
pressedKeys.current.clear();
// Handle AltGr key
altGrRef.current.handleKeyDown(code, event.timeStamp);
pressedKeys.current.add(code);
sendKeyEvent('keydown', code);
}
function handleKeyUp(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = normalizeKeyCode(event, os);
if (!code) return;
// Handle leader key release
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
if (leaderHandled) {
return;
}
// Handle AltGr key release
const altGrHandled = altGrRef.current.handleKeyUp(code);
if (altGrHandled) {
return;
}
// Release Meta combinations (macOS compatibility)
if (code === 'MetaLeft' || code === 'MetaRight') {
releaseMetaCombinations();
}
pressedKeys.current.delete(code);
sendKeyEvent('keyup', code);
}
function handleCompositionStart() {
isComposing.current = true;
}
function handleCompositionEnd() {
isComposing.current = false;
}
function handleBlur() {
releaseKeys();
}
function handleVisibilityChange() {
if (document.hidden) {
releaseAllKeys();
releaseKeys();
}
}
function releaseMetaCombinations() {
const keysToRelease = Array.from(pressedKeys.current).filter((code) => !isModifier(code));
for (const key of keysToRelease) {
sendKeyEvent('keyup', key);
pressedKeys.current.delete(key);
}
}
function releaseKeys() {
pressedKeys.current.forEach((code) => {
sendKeyEvent('keyup', code);
});
pressedKeys.current.clear();
leaderKeyRef.current.reset();
altGrRef.current.reset();
const report = keyboardRef.current.reset();
sendReport(report);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', releaseAllKeys);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
document.removeEventListener('compositionstart', handleCompositionStart);
document.removeEventListener('compositionend', handleCompositionEnd);
window.removeEventListener('blur', handleBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseKeys();
};
}, []);
}, [isKeyboardEnabled]);
function sendKeyDown(event: KeyboardEvent) {
const code = KeyboardCodes.get(event.code);
if (!code) {
console.log('unknown code: ', event.code);
return;
}
let ctrl = 0;
if (event.ctrlKey) {
if (pressedKeys.current.has('ControlLeft')) {
ctrl = 1;
} else if (pressedKeys.current.has('ControlRight')) {
ctrl = 16;
} else if (pressedKeys.current.has('AltRight')) {
ctrl = 0;
} else {
ctrl = 1;
}
}
const modifiers = [
ctrl,
event.shiftKey ? (pressedKeys.current.has('ShiftRight') ? 32 : 2) : 0,
event.altKey ? (pressedKeys.current.has('AltRight') ? 64 : 4) : 0,
event.metaKey ? (pressedKeys.current.has('MetaRight') ? 128 : 8) : 0
];
client.send([1, code, ...modifiers]);
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
client.send(data);
}
function sendKeyUp() {
client.send([1, 0, 0, 0, 0, 0]);
}
// disable the default keyboard events
function disableEvent(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
}
return <></>;
return (
<>
<Recorder recordMode={leaderKey.recordMode} recordedKeys={leaderKey.recordedKeys} />
</>
);
};

View File

@@ -1,168 +0,0 @@
export const KeyboardCodes: Map<string, number> = new Map([
['KeyA', 4],
['KeyB', 5],
['KeyC', 6],
['KeyD', 7],
['KeyE', 8],
['KeyF', 9],
['KeyG', 10],
['KeyH', 11],
['KeyI', 12],
['KeyJ', 13],
['KeyK', 14],
['KeyL', 15],
['KeyM', 16],
['KeyN', 17],
['KeyO', 18],
['KeyP', 19],
['KeyQ', 20],
['KeyR', 21],
['KeyS', 22],
['KeyT', 23],
['KeyU', 24],
['KeyV', 25],
['KeyW', 26],
['KeyX', 27],
['KeyY', 28],
['KeyZ', 29],
['RusA', 4],
['RusB', 5],
['RusC', 6],
['RusD', 7],
['RusE', 8],
['RusF', 9],
['RusG', 10],
['RusH', 11],
['RusI', 12],
['RusJ', 13],
['RusK', 14],
['RusL', 15],
['RusM', 16],
['RusN', 17],
['RusO', 18],
['RusP', 19],
['RusQ', 20],
['RusR', 21],
['RusS', 22],
['RusT', 23],
['RusU', 24],
['RusV', 25],
['RusW', 26],
['RusX', 27],
['RusY', 28],
['RusZ', 29],
['RusBracketLeft', 47],
['RusBracketRight', 48],
['RusBackslash', 49],
['RusSemicolon', 51],
['RusQuote', 52],
['RusComma', 54],
['RusPeriod', 55],
['RusSlash', 56],
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
['Digit4', 33],
['Digit5', 34],
['Digit6', 35],
['Digit7', 36],
['Digit8', 37],
['Digit9', 38],
['Digit0', 39],
['Enter', 40],
['Escape', 41],
['Backspace', 42],
['Tab', 43],
['Space', 44],
['Minus', 45],
['Equal', 46],
['BracketLeft', 47],
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Backquote_azerty', 100],
['IntlBackslash_qwertz', 100],
['Semicolon', 51],
['Quote', 52],
['Backquote', 53],
['KeyTilde', 53],
['Comma', 54],
['Period', 55],
['KeyDot', 55],
['Slash', 56],
['CapsLock', 57],
['F1', 58],
['F2', 59],
['F3', 60],
['F4', 61],
['F5', 62],
['F6', 63],
['F7', 64],
['F8', 65],
['F9', 66],
['F10', 67],
['F11', 68],
['F12', 69],
['F13', 70],
['PrintScreen', 70],
['ScrollLock', 71],
['Pause', 72],
['Insert', 73],
['Home', 74],
['PageUp', 75],
['Delete', 76],
['End', 77],
['PageDown', 78],
['ArrowRight', 79],
['ArrowLeft', 80],
['ArrowDown', 81],
['ArrowUp', 82],
['NumLock', 83],
['NumpadDivide', 84],
['NumpadMultiply', 85],
['NumpadSubtract', 86],
['NumpadAdd', 87],
['NumpadEnter', 88],
['Numpad1', 89],
['Numpad2', 90],
['Numpad3', 91],
['Numpad4', 92],
['Numpad5', 93],
['Numpad6', 94],
['Numpad7', 95],
['Numpad8', 96],
['Numpad9', 97],
['Numpad0', 98],
['NumpadDecimal', 99],
['KeyKpDot', 99],
['Menu', 118],
['ControlLeft', 224],
['ShiftLeft', 225],
['AltLeft', 226],
['MetaLeft', 227],
['ControlRight', 228],
['ShiftRight', 229],
['AltRight', 230],
['MetaRight', 231]
]);
export const ModifierCodes: Map<string, number> = new Map([
['ControlLeft', 1],
['ShiftLeft', 2],
['AltLeft', 4],
['MetaLeft', 8],
['ControlRight', 16],
['ShiftRight', 32],
['AltRight', 64],
['MetaRight', 128]
]);

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
interface RecorderProps {
recordMode: boolean;
recordedKeys: string[];
}
export const Recorder = ({ recordMode, recordedKeys }: RecorderProps) => {
const { t } = useTranslation();
if (!recordMode) {
return <></>;
}
return (
<div className="animate-in fade-in slide-in-from-top-4 pointer-events-none fixed right-5 top-5 z-[9999] flex min-w-[150px] flex-col gap-2 rounded-xl border border-white/10 bg-black/60 p-4 shadow-2xl backdrop-blur-md transition-all duration-200">
{recordedKeys.length === 0 ? (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-amber-500"></span>
</div>
<span className="text-xs font-semibold tracking-wider text-white/90">
{t('keyboard.leaderKey.recorder.rec')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<span className="text-sm italic text-white/50">
{t('keyboard.leaderKey.recorder.input')}
</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-green-500"></span>
</div>
<span className="text-xs text-white/90">
{t('keyboard.leaderKey.recorder.activate')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
{recordedKeys.map((key) => (
<span
key={key}
className="flex items-center justify-center rounded border-b-2 border-white/20 bg-white/10 px-2 py-0.5 font-mono text-sm font-medium text-white shadow-sm"
>
{key}
</span>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React, { useRef } from 'react';
const ALTGR_THRESHOLD_MS = 10;
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
export interface AltGrHandlers {
handleKeyDown: (code: string, timestamp: number) => void;
handleKeyUp: (code: string) => boolean;
reset: () => void;
isActive: () => boolean;
}
export function useAltGr(
os: string,
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): AltGrHandlers {
const state = useRef<AltGrState | null>(null);
if (os === 'Windows' && !state.current) {
state.current = { active: false, ctrlLeftTimestamp: 0 };
}
const handleKeyDown = (code: string, timestamp: number): void => {
if (!state.current) return;
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (code === 'ControlLeft') {
state.current.ctrlLeftTimestamp = timestamp;
return;
}
if (code === 'AltRight') {
const timeDiff = timestamp - state.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
sendKeyEvent('keyup', 'ControlLeft');
state.current.active = true;
return;
}
}
};
const handleKeyUp = (code: string): boolean => {
if (!state.current?.active) {
return false;
}
if (code === 'ControlLeft') {
pressedKeys.current.delete('ControlLeft');
return true;
}
if (code === 'AltRight') {
pressedKeys.current.delete('ControlLeft');
state.current.active = false;
return false;
}
return false;
};
const reset = () => {
if (state.current) {
state.current.active = false;
state.current.ctrlLeftTimestamp = 0;
}
};
const isActive = () => state.current?.active ?? false;
return { handleKeyDown, handleKeyUp, reset, isActive };
}

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import * as api from '@/api/hid.ts';
import { isModifier } from '@/lib/keymap';
import { leaderKeyAtom } from '@/jotai/keyboard.ts';
interface LeaderKeyState {
code: string;
startTime: number;
recordMode: boolean;
recordModifiers: string[];
}
interface LeaderKeyHandlers {
handleKeyDown: (code: string) => boolean;
handleKeyUp: (code: string) => boolean;
reset: () => void;
recordMode: boolean;
recordedKeys: string[];
}
const RECORD_MODE_THRESHOLD_MS = 350;
export function useLeaderKey(
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): LeaderKeyHandlers {
const [leaderKeyCode, setLeaderKeyCode] = useAtom(leaderKeyAtom);
const [recordMode, setRecordMode] = useState(false);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const leaderState = useRef<LeaderKeyState>({
code: '',
startTime: 0,
recordMode: false,
recordModifiers: []
});
// Get leader key
useEffect(() => {
api.getLeaderKey().then((rsp: any) => {
if (rsp.code === 0 && rsp.data?.key !== undefined) {
setLeaderKeyCode(rsp.data.key);
}
});
}, [setLeaderKeyCode]);
// Update leader key
useEffect(() => {
leaderState.current.code = leaderKeyCode;
}, [leaderKeyCode]);
const handleKeyDown = (code: string): boolean => {
const leader = leaderState.current;
// Leader key pressed
if (leader.code && code === leader.code) {
if (!leader.recordMode) {
// Enter record mode
leader.startTime = Date.now();
leader.recordMode = true;
setRecordMode(true);
}
pressedKeys.current.add(code);
return true;
}
// Record mode
if (leader.recordMode) {
if (isModifier(code)) {
// Modifier: send keydown immediately and track it
if (!leader.recordModifiers.includes(code)) {
leader.recordModifiers.push(code);
setRecordedKeys([...leaderState.current.recordModifiers]);
sendKeyEvent('keydown', code);
}
} else {
// Non-modifier: send keydown + keyup immediately
sendKeyEvent('keydown', code);
sendKeyEvent('keyup', code);
}
pressedKeys.current.add(code);
return true;
}
return false;
};
const handleKeyUp = (code: string): boolean => {
const leader = leaderState.current;
// Leader key released
if (leader.code && code === leader.code) {
if (leader.recordMode && Date.now() - leader.startTime > RECORD_MODE_THRESHOLD_MS) {
// Exit record mode
reset();
}
pressedKeys.current.delete(code);
return true;
}
// In record mode, handle keyup
if (leader.recordMode && code !== leader.code) {
pressedKeys.current.delete(code);
return true;
}
return false;
};
const reset = () => {
const leader = leaderState.current;
for (const key of leader.recordModifiers) {
sendKeyEvent('keyup', key);
}
leader.startTime = 0;
leader.recordMode = false;
leader.recordModifiers = [];
setRecordMode(false);
setRecordedKeys([]);
};
return { handleKeyDown, handleKeyUp, reset, recordMode, recordedKeys };
}

View File

@@ -0,0 +1,26 @@
export function normalizeKeyCode(event: KeyboardEvent, os?: string): string {
if (event.code) {
return event.code;
}
// Fallback: use event.key + event.location to determine the key
// event.location: 1 = left, 2 = right, 0 = standard (non-positional)
if (event.key === 'Shift') {
if (event.location === 0 && os === 'Windows') {
return 'ShiftRight';
}
return event.location === 2 ? 'ShiftRight' : 'ShiftLeft';
}
if (event.key === 'Control') {
return event.location === 2 ? 'ControlRight' : 'ControlLeft';
}
if (event.key === 'Alt') {
return event.location === 2 ? 'AltRight' : 'AltLeft';
}
if (event.key === 'Meta') {
return event.location === 2 ? 'MetaRight' : 'MetaLeft';
}
return event.code;
}

View File

@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
import { Divider, Modal, Segmented } from 'antd';
import { Divider, Modal, Segmented, Tooltip } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { DiscIcon, HardDriveIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage.ts';
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
import { Images } from './images.tsx';
import { Tips } from './tips.tsx';
export const Image = () => {
const { t } = useTranslation();
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
@@ -51,19 +54,26 @@ export const Image = () => {
});
}, []);
function toggleModal(open: boolean) {
setIsModalOpen(open);
setSubmenuOpenCount((count) => (open ? count + 1 : Math.max(0, count - 1)));
}
return (
<>
<div
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
onClick={() => setIsModalOpen(true)}
>
<DiscIcon size={18} />
</div>
<Tooltip title={t('image.title')} placement="bottom" mouseEnterDelay={0.6}>
<div
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
onClick={() => toggleModal(true)}
>
<DiscIcon size={18} />
</div>
</Tooltip>
<Modal open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)}>
<Modal open={isModalOpen} footer={null} onCancel={() => toggleModal(false)}>
<div className="flex items-center space-x-1">
<span className="text-xl font-bold">{t('image.title')}</span>
<Tips />

View File

@@ -1,19 +1,20 @@
import { useEffect, useRef, useState } from 'react';
import { Divider, Tooltip } from 'antd';
import { useRef } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { MenuIcon, XIcon } from 'lucide-react';
import Draggable from 'react-draggable';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { GripVerticalIcon } from 'lucide-react';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
import { DownloadImage } from './download.tsx';
import { Fullscreen } from './fullscreen';
import { Image } from './image';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Collapse, Expand } from './operations';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
@@ -22,79 +23,85 @@ import { Terminal } from './terminal';
import { Wol } from './wol';
export const Menu = () => {
const { t } = useTranslation();
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
const nodeRef = useRef<HTMLDivElement | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const nodeRef = useRef<any>(null);
const {
isInitialized,
isMenuExpanded,
isMenuHidden,
handleHovered,
handleMoved,
setIsMenuExpanded
} = useMenuVisibility();
useEffect(() => {
// disabled menu items
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
const menuBounds = useMenuBounds(nodeRef, isMenuExpanded);
// react-draggable bounds
const handleResize = () => {
if (!nodeRef.current) return;
function onDragStop(_e: DraggableEvent, data: DraggableData) {
if (data.x === 0 && data.y === 0) return;
handleMoved();
}
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
function isEnabled(item: string) {
return !menuDisabledItems.includes(item);
}
return (
<Draggable
nodeRef={nodeRef}
bounds={bounds}
bounds={menuBounds}
handle="strong"
positionOffset={{ x: '-50%', y: '0%' }}
onStop={onDragStop}
>
<div ref={nodeRef} className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
<div
ref={nodeRef}
className={clsx(
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
isInitialized ? 'opacity-100' : 'opacity-0'
)}
onMouseEnter={() => handleHovered(true)}
onMouseLeave={() => handleHovered(false)}
onBlur={() => handleHovered(false)}
>
{/* Trigger area for auto-show when hidden */}
{isMenuExpanded && (
<div className="absolute -top-[10px] left-0 right-0 h-[46px] w-full bg-transparent" />
)}
{/* Menubar */}
<div className="sticky top-[10px] flex w-full justify-center">
<div
className={clsx(
'h-[36px] items-center rounded bg-neutral-800/80',
isMenuOpen ? 'flex' : 'hidden'
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
isMenuExpanded ? 'flex' : 'hidden',
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
)}
>
<strong>
<div className="hidden h-[30px] cursor-move select-none items-center px-3 sm:flex">
<img src="/sipeed.ico" width={18} height={18} draggable={false} alt="sipeed" />
<div className="flex h-[30px] cursor-move select-none items-center justify-center pl-1 text-neutral-500">
<GripVerticalIcon size={18} />
</div>
</strong>
<Divider type="vertical" />
<Screen />
<Keyboard />
<Mouse />
<Divider type="vertical" />
{!menuDisabledItems.includes('image') && <Image />}
{!menuDisabledItems.includes('download') && <DownloadImage />}
{!menuDisabledItems.includes('script') && <Script />}
{!menuDisabledItems.includes('terminal') && <Terminal />}
{!menuDisabledItems.includes('wol') && <Wol />}
{isEnabled('image') && <Image />}
{isEnabled('download') && <DownloadImage />}
{isEnabled('script') && <Script />}
{isEnabled('terminal') && <Terminal />}
{isEnabled('wol') && <Wol />}
{['image', 'script', 'terminal', 'wol', 'download'].some(
(key) => !menuDisabledItems.includes(key)
) && <Divider type="vertical" />}
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (
<Divider type="vertical" />
)}
{!menuDisabledItems.includes('power') && (
{isEnabled('power') && (
<>
<Power />
<Divider type="vertical" />
@@ -102,29 +109,13 @@ export const Menu = () => {
)}
<Settings />
<Fullscreen />
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="mr-1 flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<XIcon size={20} />
</div>
</Tooltip>
{isEnabled('fullscreen') && <Fullscreen />}
{isEnabled('collapse') && <Collapse toggleMenu={setIsMenuExpanded} />}
</div>
</div>
{!isMenuOpen && (
<Tooltip title={t('menu.expand')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="flex h-[30px] w-[32px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-700 hover:text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon size={20} />
</div>
</Tooltip>
)}
{/* Menubar expand button */}
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
</div>
</Draggable>
);

View File

@@ -1,31 +0,0 @@
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { OctagonMinus } from 'lucide-react';
import { KeyboardCodes, ModifierCodes } from '@/pages/desktop/keyboard/mappings.ts';
import { client } from '@/lib/websocket.ts';
export const CtrlAltDel = () => {
const { t } = useTranslation();
function sendCtrlAltDel() {
const ctrl = ModifierCodes.get('ControlLeft')!;
const alt = ModifierCodes.get('AltLeft')!;
const del = KeyboardCodes.get('Delete')!;
client.send([1, del, ctrl, 0, alt, 0]);
client.send([1, 0, 0, 0, 0, 0]);
};
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

@@ -1,26 +1,30 @@
import { Divider } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { MenuItem } from '@/components/menu-item.tsx';
import { CtrlAltDel } from './ctrl-alt-del.tsx';
import { LeaderKey } from './leader-key.tsx';
import { Paste } from './paste.tsx';
import { Shortcuts } from './shortcuts';
import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
const { t } = useTranslation();
const content = (
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<Shortcuts />
<Divider style={{ margin: '5px 0', opacity: 0.5 }} />
<LeaderKey />
</div>
);
return (
<MenuItem
title={t('keyboard.title')}
icon={<KeyboardIcon size={18} />}
content={
<>
<Paste />
<VirtualKeyboard />
<CtrlAltDel />
</>
}
/>
<MenuItem title={t('keyboard.title')} icon={<KeyboardIcon size={18} />} content={content} />
);
};

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { Button, Input, Modal, Switch } from 'antd';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import {
ArrowBigUpIcon,
ChevronRightIcon,
ChevronUpIcon,
CommandIcon,
KeyboardIcon,
KeyIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { isKeyboardEnableAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
export const LeaderKey = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [leaderKey, setLeaderKey] = useAtom(leaderKeyAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLeaderKeyEnable, setIsLeaderKeyEnable] = useState(false);
const [tempLeaderKey, setTempLeaderKey] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [isDocCollapsed, setIsDocCollapsed] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const optionalKeys = [
{
value: 'ShiftRight',
label: t('keyboard.leaderKey.shiftRight'),
icon: <ArrowBigUpIcon size={16} />
},
{
value: 'ControlRight',
label: t('keyboard.leaderKey.ctrlRight'),
icon: <ChevronUpIcon size={16} />
},
{
value: 'MetaRight',
label: t('keyboard.leaderKey.metaRight'),
icon: <CommandIcon size={16} />
}
];
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
setTempLeaderKey(event.code);
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isFocused]);
function openModal() {
setIsKeyboardEnable(false);
setIsLeaderKeyEnable(!!leaderKey);
setTempLeaderKey(leaderKey);
setIsDocCollapsed(true);
setIsModalOpen(true);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function submit() {
if (isLoading) return;
const key = isLeaderKeyEnable ? tempLeaderKey : '';
if (key === leaderKey) {
closeModal();
return;
}
setIsLoading(true);
api
.setLeaderKey(key)
.then((rsp) => {
if (rsp.code === 0) {
setLeaderKey(key);
closeModal();
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
onClick={openModal}
>
<KeyIcon size={18} />
<span>{t('keyboard.leaderKey.title')}</span>
</div>
<Modal width={510} keyboard={false} footer={null} open={isModalOpen} onCancel={closeModal}>
<div className="my-3 flex flex-col space-y-3">
{/* title */}
<div className="flex flex-col space-y-1">
<span className="text-lg"> {t('keyboard.leaderKey.title')}</span>
<span className="text-xs text-neutral-500">{t('keyboard.leaderKey.desc')}</span>
</div>
{/* Document */}
<div className="rounded-lg bg-neutral-800 py-3">
<div
className="flex cursor-pointer items-center justify-between px-5"
onClick={() => setIsDocCollapsed((c) => !c)}
>
<span>{t('keyboard.leaderKey.howToUse')}</span>
<div
className={clsx(
'transition-300 size-[18px] text-neutral-500 transition',
isDocCollapsed ? 'rotate-0' : 'rotate-90'
)}
>
<ChevronRightIcon size={18} />
</div>
</div>
{!isDocCollapsed && (
<ul className="list-outside space-y-3">
{['simultaneous', 'sequential'].map((key) => (
<li>
<div className="flex flex-col space-y-2 pr-1">
<span className="">{t(`keyboard.leaderKey.${key}.title`)}</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc1`)}
</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc2`)}
</span>
</div>
</li>
))}
</ul>
)}
</div>
{/* Switch */}
<div className="flex items-center justify-between rounded-lg bg-neutral-800 px-5 py-3">
<span className="select-none">{t('keyboard.leaderKey.enable')}</span>
<Switch value={isLeaderKeyEnable} onChange={setIsLeaderKeyEnable} />
</div>
{/* Keyboard recorder */}
{isLeaderKeyEnable && (
<div className="flex flex-col space-y-5 rounded-lg bg-neutral-800 px-5 py-5">
<span className="text-sm text-neutral-400">{t('keyboard.leaderKey.tip')}</span>
<Input
value={tempLeaderKey}
className="flex-1"
placeholder={t('keyboard.leaderKey.placeholder')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<div className="flex items-center justify-center space-x-3">
{optionalKeys.map((key) => (
<div
key={key.value}
className={clsx(
'flex w-28 cursor-pointer items-center justify-center space-x-1 rounded py-1 shadow-lg shadow-neutral-800 outline',
key.value === tempLeaderKey
? 'text-neutral-300 outline-blue-500'
: 'text-neutral-500 outline-neutral-700 hover:shadow-xl hover:outline-neutral-600'
)}
onClick={() => setTempLeaderKey((k) => (k === key.value ? '' : key.value))}
>
{key.icon}
<span>{key.label}</span>
</div>
))}
</div>
</div>
)}
<div className="flex w-full justify-center pt-5">
<Button className="w-24" type="primary" loading={isLoading} onClick={submit}>
{t('keyboard.leaderKey.submit')}
</Button>
</div>
</div>
</Modal>
</>
);
};

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

View File

@@ -4,7 +4,7 @@ import { RefreshCwIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { client } from '@/lib/websocket.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
export const ResetHid = () => {
const { t } = useTranslation();
@@ -15,7 +15,10 @@ export const ResetHid = () => {
if (isResetting) return;
setIsResetting(true);
client.send([1, 0, 0, 0, 0, 0]);
// Release keyboard keys
const data = new Uint8Array([MessageEvent.Keyboard, 0, 0, 0, 0, 0, 0, 0, 0]);
client.send(data);
client.close();
api.reset().finally(() => {

View File

@@ -39,7 +39,7 @@ export const Speed = () => {
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<div className="h-[150px] min-w-[60px] py-3">
<Slider
vertical
marks={{

View File

@@ -0,0 +1,22 @@
import { Tooltip } from 'antd';
import { XIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface CollapseProps {
toggleMenu(expanded: boolean): void;
}
export const Collapse = ({ toggleMenu }: CollapseProps) => {
const { t } = useTranslation();
return (
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="flex size-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
onClick={() => toggleMenu(false)}
>
<XIcon size={18} />
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,27 @@
import { Divider } from 'antd';
import { ChevronRightIcon, GripVerticalIcon } from 'lucide-react';
interface ExpandProps {
toggleMenu: (expanded: boolean) => void;
}
export const Expand = ({ toggleMenu }: ExpandProps) => {
return (
<div className="flex items-center rounded-lg bg-neutral-800/50 p-1">
<strong>
<div className="flex size-[26px] cursor-move select-none items-center justify-center text-neutral-500">
<GripVerticalIcon size={18} />
</div>
</strong>
<Divider type="vertical" style={{ margin: '0 4px' }} />
<div
className="flex size-[26px] cursor-pointer items-center justify-center rounded text-neutral-500 hover:bg-neutral-800/60 hover:text-white"
onClick={() => toggleMenu(true)}
>
<ChevronRightIcon size={18} />
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { Collapse } from './collapse.tsx';
export { Expand } from './expand.tsx';

View File

@@ -48,7 +48,12 @@ export const Power = () => {
}
const icon = (
<div className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-300')}>
<div
className={clsx(
'h-[18px] w-[18px]',
isPowerOn ? 'text-green-600' : 'text-neutral-300 hover:text-white'
)}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (

View File

@@ -2,7 +2,8 @@ import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { Language } from './language.tsx';
import { MenuBar } from './menu-bar.tsx';
import { MenuIcons } from './menu-icons.tsx';
import { MenuMode } from './menu-mode.tsx';
import { WebTitle } from './web-title.tsx';
export const Appearance = () => {
@@ -19,7 +20,9 @@ export const Appearance = () => {
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
<MenuBar />
<div className="text-neutral-400">{t('settings.appearance.menuBar.title')}</div>
<MenuMode />
<MenuIcons />
</>
);
};

View File

@@ -4,27 +4,31 @@ import {
DiscIcon,
DownloadIcon,
FileJsonIcon,
MaximizeIcon,
NetworkIcon,
PowerIcon,
TerminalSquareIcon
TerminalSquareIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage.ts';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export const MenuBar = () => {
export const MenuIcons = () => {
const { t } = useTranslation();
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
const items = [
{ key: 'image', icon: <DiscIcon size={16} className="text-neutral-400" /> },
{ key: 'download', icon: <DownloadIcon size={16} className="text-neutral-400" /> },
{ key: 'script', icon: <FileJsonIcon size={16} className="text-neutral-400" /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} className="text-neutral-400" /> },
{ key: 'wol', icon: <NetworkIcon size={16} className="text-neutral-400" /> },
{ key: 'power', icon: <PowerIcon size={16} className="text-neutral-400" /> }
{ key: 'image', icon: <DiscIcon size={16} /> },
{ key: 'download', icon: <DownloadIcon size={16} /> },
{ key: 'script', icon: <FileJsonIcon size={16} /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
{ key: 'wol', icon: <NetworkIcon size={16} /> },
{ key: 'power', icon: <PowerIcon size={16} /> },
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
];
function updateItems(key: string) {
@@ -39,19 +43,24 @@ export const MenuBar = () => {
}
return (
<>
<div className="mt-8 flex flex-col space-y-5">
<div className="flex flex-col">
<span className="text-neutral-400">{t('settings.appearance.menuBar')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.menuBarDesc')}</span>
<span className="text-neutral-400">{t('settings.appearance.menuBar.icons')}</span>
<span className="text-xs text-neutral-500">
{t('settings.appearance.menuBar.iconsDesc')}
</span>
</div>
<div className="mt-8 flex flex-col space-y-5">
<div className="mt-5 flex flex-col space-y-5">
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 text-neutral-400">
{item.icon}
<span>{t(`${item.key}.title`)}</span>
<span className="text-neutral-300">
{item.label ? t(item.label) : t(`${item.key}.title`)}
</span>
</div>
<Switch
value={!menuDisabledItems.includes(item.key)}
onChange={() => updateItems(item.key)}
@@ -59,6 +68,6 @@ export const MenuBar = () => {
</div>
))}
</div>
</>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { Segmented } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { menuDisplayModeAtom } from '@/jotai/settings.ts';
export const MenuMode = () => {
const { t } = useTranslation();
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const options = [
// { value: 'off', label: t('settings.appearance.menuBar.modeOff') },
{ value: 'auto', label: t('settings.appearance.menuBar.modeAuto') },
{ value: 'always', label: t('settings.appearance.menuBar.modeAlways') }
];
function handleChange(mode: string) {
if (mode === menuDisplayMode) return;
setMenuDisplayMode(mode);
storage.setMenuDisplayMode(mode);
}
return (
<div className="mt-5 flex w-full items-center justify-between">
<div className="flex flex-col">
<span className="text-neutral-400">{t('settings.appearance.menuBar.mode')}</span>
<span className="text-xs text-neutral-500">
{t('settings.appearance.menuBar.modeDesc')}
</span>
</div>
<Segmented value={menuDisplayMode} options={options} onChange={handleChange} />
</div>
);
};

View File

@@ -16,6 +16,7 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import * as ls from '@/lib/localstorage.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -35,6 +36,7 @@ export const Settings = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
const tabs = [
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
@@ -91,6 +93,7 @@ export const Settings = () => {
function openModal() {
setIsModalOpen(true);
setIsKeyboardEnable(false);
setSubmenuOpenCount((count) => count + 1);
}
function closeModal() {
@@ -101,17 +104,18 @@ export const Settings = () => {
setIsKeyboardEnable(true);
setIsModalOpen(false);
setCurrentTab('about');
setSubmenuOpenCount((count) => Math.max(0, count - 1));
}
return (
<>
<Tooltip title={t('settings.title')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/80"
className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700/80"
onClick={openModal}
>
<Badge dot={isUpdateAvailable} color="blue" offset={[0, 2]}>
<div className="pt-[3px]">
<div className="pt-[3px] text-neutral-300 hover:text-white">
<SettingsIcon size={18} />
</div>
</Badge>

View File

@@ -13,8 +13,6 @@ type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
@@ -85,7 +83,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<div className="text-base">{t('settings.update.title')}</div>
<Divider className="opacity-50" />
<Preview />
<Preview checkForUpdates={checkForUpdates} />
<Offline
status={status}
setStatus={setStatus}

View File

@@ -5,7 +5,11 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/application.ts';
export const Preview = () => {
interface PreviewProps {
checkForUpdates: () => void;
}
export const Preview = ({ checkForUpdates }: PreviewProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
@@ -48,6 +52,7 @@ export const Preview = () => {
}
setIsEnabled(enable);
checkForUpdates();
})
.finally(() => {
setIsLoading(false);

View File

@@ -213,7 +213,7 @@ export const Wol = () => {
return (
<MenuItem
title={t('wol.title')}
icon={<NetworkIcon size={16} />}
icon={<NetworkIcon size={18} />}
content={content}
onOpenChange={handleOpenChange}
/>

View File

@@ -1,95 +1,271 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { useMediaQuery } from 'react-responsive';
import { client } from '@/lib/websocket.ts';
import { MouseReportAbsolute } from '@/lib/mouse.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { MouseButton, MouseEvent } from './constants';
import { MouseAbsoluteEvent } from './types.ts';
enum MouseButton {
Left = 0,
Middle = 1,
Right = 2,
Back = 3,
Forward = 4
}
export const Absolute = () => {
const isBigScreen = useMediaQuery({ minWidth: 650 });
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const mouseRef = useRef(new MouseReportAbsolute());
const lastPosRef = useRef({ x: 0.5, y: 0.5 });
const lastScrollTimeRef = useRef(0);
const mouseButtonMapping = (button: number) => {
const mappings = [MouseButton.Left, MouseButton.Wheel, MouseButton.Right];
return mappings[button] || MouseButton.None;
};
// For touch events
const touchStartTimeRef = useRef(0);
const lastTouchYRef = useRef(0);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLongPressRef = useRef(false);
const hasMoveRef = useRef(false);
const isDraggingRef = useRef(false);
const pressedButtonRef = useRef<MouseButton | null>(null);
const touchStartPosRef = useRef({ x: 0, y: 0 });
const TAP_THRESHOLD = 8;
const DRAG_THRESHOLD = 10;
const VELOCITY_THRESHOLD = 0.3;
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('screen') as HTMLVideoElement;
if (!canvas) return;
const screen = document.getElementById('screen') as HTMLVideoElement;
if (!screen) return;
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
canvas.addEventListener('click', disableEvent);
canvas.addEventListener('contextmenu', disableEvent);
screen.addEventListener('mousedown', handleMouseDown);
screen.addEventListener('mouseup', handleMouseUp);
screen.addEventListener('mousemove', handleMouseMove);
screen.addEventListener('wheel', handleWheel);
screen.addEventListener('click', disableEvent);
screen.addEventListener('contextmenu', disableEvent);
// press button
function handleMouseDown(event: any) {
disableEvent(event);
const button: MouseButton = mouseButtonMapping(event.button);
if (button === MouseButton.None) return;
const data = [2, MouseEvent.Down, button, 0, 0];
client.send(data);
if (isBigScreen) {
screen.addEventListener('touchstart', handleTouchStart);
screen.addEventListener('touchmove', handleTouchMove);
screen.addEventListener('touchend', handleTouchEnd);
screen.addEventListener('touchcancel', handleTouchCancel);
}
// release button
function handleMouseUp(event: any) {
disableEvent(event);
const data = [2, MouseEvent.Up, MouseButton.None, 0, 0];
client.send(data);
// Mouse down event
function handleMouseDown(e: MouseEvent) {
disableEvent(e);
handleMouseEvent({ type: 'mousedown', button: e.button });
}
// mouse move
function handleMouseMove(event: any) {
disableEvent(event);
const { x, y } = getCoordinate(event);
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, x, y];
client.send(data);
// Mouse up event
function handleMouseUp(e: MouseEvent) {
disableEvent(e);
handleMouseEvent({ type: 'mouseup', button: e.button });
}
// mouse scroll
function handleWheel(event: any) {
disableEvent(event);
// Mouse move event
function handleMouseMove(e: MouseEvent) {
disableEvent(e);
const { x, y } = getCoordinate(e);
handleMouseEvent({ type: 'move', x, y });
}
if (Math.floor(event.deltaY) === 0) return;
// Mouse wheel event
function handleWheel(e: WheelEvent) {
disableEvent(e);
if (Math.floor(e.deltaY) === 0) {
return;
}
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
lastScrollTimeRef.current = currentTime;
const deltaY = (event.deltaY > 0 ? 1 : -1) * scrollDirection;
const data = [2, MouseEvent.Scroll, 0, 0, deltaY];
client.send(data);
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastScrollTimeRef.current = currentTime;
}
// Mouse touch start event
function handleTouchStart(e: TouchEvent) {
disableEvent(e);
if (e.touches.length === 0) {
return;
}
const touch = e.touches[0];
// Reset states
touchStartTimeRef.current = Date.now();
lastTouchYRef.current = touch.clientY;
isLongPressRef.current = false;
hasMoveRef.current = false;
isDraggingRef.current = false;
pressedButtonRef.current = null;
touchStartPosRef.current = { x: touch.clientX, y: touch.clientY };
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
const { x, y } = getCoordinate(touch);
handleMouseEvent({ type: 'move', x, y });
if (e.touches.length > 1) {
return;
}
// Start long press
longPressTimerRef.current = setTimeout(() => {
isLongPressRef.current = true;
pressedButtonRef.current = MouseButton.Right;
if (navigator.vibrate) {
navigator.vibrate(50);
}
handleMouseEvent({ type: 'mousedown', button: MouseButton.Right });
}, 800);
}
// Mouse touch move event
function handleTouchMove(e: TouchEvent) {
disableEvent(e);
if (e.touches.length === 0) {
return;
}
const touch = e.touches[0];
// Handle two-finger scroll first
if (e.touches.length > 1) {
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
const deltaY = (touch.clientY - lastTouchYRef.current > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastTouchYRef.current = touch.clientY;
lastScrollTimeRef.current = currentTime;
return;
}
const deltaX = Math.abs(touch.clientX - touchStartPosRef.current.x);
const deltaY = Math.abs(touch.clientY - touchStartPosRef.current.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const timeDelta = Date.now() - touchStartTimeRef.current;
const velocity = timeDelta > 0 ? distance / timeDelta : 0;
const shouldStartDrag =
distance > DRAG_THRESHOLD || (distance > TAP_THRESHOLD && velocity > VELOCITY_THRESHOLD);
if (shouldStartDrag && !isDraggingRef.current && !isLongPressRef.current) {
if (!hasMoveRef.current) {
hasMoveRef.current = true;
}
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (pressedButtonRef.current === null) {
isDraggingRef.current = true;
pressedButtonRef.current = MouseButton.Left;
handleMouseEvent({ type: 'mousedown', button: MouseButton.Left });
}
}
if (distance > TAP_THRESHOLD && !hasMoveRef.current) {
hasMoveRef.current = true;
}
if (isDraggingRef.current || isLongPressRef.current) {
const { x, y } = getCoordinate(touch);
handleMouseEvent({ type: 'move', x, y });
}
}
// Mouse touch end event
function handleTouchEnd(e: TouchEvent) {
disableEvent(e);
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (!hasMoveRef.current && !isLongPressRef.current) {
handleMouseEvent({ type: 'mousedown', button: MouseButton.Left });
setTimeout(() => {
handleMouseEvent({ type: 'mouseup', button: MouseButton.Left });
}, 50);
} else if (pressedButtonRef.current !== null) {
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
}
isLongPressRef.current = false;
hasMoveRef.current = false;
isDraggingRef.current = false;
pressedButtonRef.current = null;
}
// Mouse touch cancel event
function handleTouchCancel(e: any) {
disableEvent(e);
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (pressedButtonRef.current) {
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
}
isLongPressRef.current = false;
hasMoveRef.current = false;
isDraggingRef.current = false;
pressedButtonRef.current = null;
}
// get mouse coordinate
function getCoordinate(event: any) {
const { x, y } = getCorrectedCoords(event.clientX, event.clientY);
const finalX = Math.max(0, Math.min(1, x));
const finalY = Math.max(0, Math.min(1, y));
const hexX = Math.floor(0x7fff * finalX) + 0x0001;
const hexY = Math.floor(0x7fff * finalY) + 0x0001;
return { x: hexX, y: hexY };
}
function getCorrectedCoords(clientX: number, clientY: number) {
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = screen.getBoundingClientRect();
const rect = canvas.getBoundingClientRect();
if (!canvas.videoWidth || !canvas.videoHeight) {
if (!screen.videoWidth || !screen.videoHeight) {
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
return { x, y };
}
const videoRatio = canvas.videoWidth / canvas.videoHeight;
const videoRatio = screen.videoWidth / screen.videoHeight;
const elementRatio = rect.width / rect.height;
let renderedWidth = rect.width;
@@ -111,27 +287,57 @@ export const Absolute = () => {
return { x, y };
}
function getCoordinate(event: any): { x: number; y: number } {
const { x, y } = getCorrectedCoords(event.clientX, event.clientY);
return () => {
screen.removeEventListener('mousemove', handleMouseMove);
screen.removeEventListener('mousedown', handleMouseDown);
screen.removeEventListener('mouseup', handleMouseUp);
screen.removeEventListener('wheel', handleWheel);
screen.removeEventListener('click', disableEvent);
screen.removeEventListener('contextmenu', disableEvent);
screen.removeEventListener('touchstart', handleTouchStart);
screen.removeEventListener('touchmove', handleTouchMove);
screen.removeEventListener('touchend', handleTouchEnd);
screen.removeEventListener('touchcancel', handleTouchCancel);
const finalX = Math.max(0, Math.min(1, x));
const finalY = Math.max(0, Math.min(1, y));
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
};
}, [isBigScreen, resolution, scrollDirection, scrollInterval]);
const hexX = Math.floor(0x7fff * finalX) + 0x0001;
const hexY = Math.floor(0x7fff * finalY) + 0x0001;
// Mouse event handler
function handleMouseEvent(event: MouseAbsoluteEvent) {
let report: Uint8Array;
const mouse = mouseRef.current;
return { x: hexX, y: hexY };
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'wheel':
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.x, event.y);
lastPosRef.current = { x: event.x, y: event.y };
break;
default:
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y);
break;
}
return () => {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('click', disableEvent);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollDirection, scrollInterval]);
sendReport(report);
}
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Mouse, ...report]);
client.send(data);
}
// disable default events
function disableEvent(event: any) {

View File

@@ -1,14 +0,0 @@
export enum MouseEvent {
Up = 0,
Down = 1,
MoveAbsolute = 2,
MoveRelative = 3,
Scroll = 4
}
export enum MouseButton {
None = 0,
Left = 1,
Right = 2,
Wheel = 4
}

View File

@@ -3,11 +3,12 @@ import { message } from 'antd';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { client } from '@/lib/websocket.ts';
import { MouseReportRelative } from '@/lib/mouse.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { MouseButton, MouseEvent } from './constants';
import { MouseRelativeEvent } from './types.ts';
export const Relative = () => {
const { t } = useTranslation();
@@ -17,17 +18,125 @@ export const Relative = () => {
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const mouseRef = useRef(new MouseReportRelative());
const isLockedRef = useRef(false);
const buttonRef = useRef<MouseButton>(MouseButton.None);
const lastScrollTimeRef = useRef(0);
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('screen');
if (!canvas) return;
const screen = document.getElementById('screen');
if (!screen) return;
showMessage();
screen.addEventListener('click', handleMouseClick);
screen.addEventListener('mousedown', handleMouseDown);
screen.addEventListener('mouseup', handleMouseUp);
screen.addEventListener('mousemove', handleMouseMove);
screen.addEventListener('wheel', handleMouseWheel, { passive: false });
screen.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('pointerlockchange', handlePointerLockChange);
// Mouse click event
function handleMouseClick(event: MouseEvent) {
disableEvent(event);
if (!isLockedRef.current) {
screen?.requestPointerLock();
}
}
// Mouse down event
function handleMouseDown(e: MouseEvent) {
disableEvent(e);
handleMouseEvent({ type: 'mousedown', button: e.button });
}
// Mouse up event
function handleMouseUp(e: MouseEvent) {
disableEvent(e);
handleMouseEvent({ type: 'mouseup', button: e.button });
}
// Mouse move event
function handleMouseMove(e: any) {
disableEvent(e);
const x = e.movementX || e.mozMovementX || e.webkitMovementX || 0;
const y = e.movementY || e.mozMovementY || e.webkitMovementY || 0;
if (x === 0 && y === 0) return;
const deltaX = Math.abs(x * window.devicePixelRatio) < 10 ? x * 2 : x;
const deltaY = Math.abs(y * window.devicePixelRatio) < 10 ? y * 2 : y;
handleMouseEvent({ type: 'move', deltaX, deltaY });
}
// Mouse wheel event
function handleMouseWheel(e: WheelEvent) {
disableEvent(e);
if (Math.floor(e.deltaY) === 0) {
return;
}
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastScrollTimeRef.current = currentTime;
}
function handlePointerLockChange() {
isLockedRef.current = document.pointerLockElement === screen;
}
return () => {
screen.removeEventListener('click', handleMouseClick);
screen.removeEventListener('mousemove', handleMouseMove);
screen.removeEventListener('mousedown', handleMouseDown);
screen.removeEventListener('mouseup', handleMouseUp);
screen.removeEventListener('wheel', handleMouseWheel);
screen.removeEventListener('contextmenu', disableEvent);
document.removeEventListener('pointerlockchange', handlePointerLockChange);
};
}, [resolution, scrollDirection, scrollInterval]);
// Mouse handler
function handleMouseEvent(event: MouseRelativeEvent) {
let report: Uint8Array;
const mouse = mouseRef.current;
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport();
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport();
break;
case 'wheel':
report = mouse.buildReport(0, 0, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.deltaX, event.deltaY);
break;
default:
report = mouse.buildReport(0, 0);
break;
}
const data = new Uint8Array([MessageEvent.Mouse, ...report]);
client.send(data);
}
// show message
function showMessage() {
messageApi.open({
key: 'no_mouse_relative',
key: 'requestPointer',
type: 'info',
content: t('mouse.requestPointer'),
duration: 3,
@@ -35,108 +144,7 @@ export const Relative = () => {
marginTop: '40vh'
}
});
document.addEventListener('pointerlockchange', handlePointerLockChange);
canvas.addEventListener('click', handleClick);
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
canvas.addEventListener('contextmenu', disableEvent);
function handleClick(event: any) {
disableEvent(event);
if (!isLockedRef.current) {
canvas!.requestPointerLock();
}
}
function handlePointerLockChange() {
isLockedRef.current = document.pointerLockElement === canvas;
}
// press button
function handleMouseDown(event: any) {
disableEvent(event);
let button: MouseButton;
switch (event.button) {
case 0:
button = MouseButton.Left;
break;
case 1:
button = MouseButton.Wheel;
break;
case 2:
button = MouseButton.Right;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
buttonRef.current = button;
const data = [2, MouseEvent.Down, button, 0, 0];
client.send(data);
}
// release button
function handleMouseUp(event: any) {
disableEvent(event);
buttonRef.current = MouseButton.None;
const data = [2, MouseEvent.Up, MouseButton.None, 0, 0];
client.send(data);
}
// mouse move
function handleMouseMove(event: any) {
disableEvent(event);
const x = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
const y = event.movementY || event.mozMovementY || event.webkitMovementY || 0;
if (x === 0 && y === 0) return;
const data = [
2,
MouseEvent.MoveRelative,
buttonRef.current,
Math.abs(x) < 10 ? x * 2 : x,
Math.abs(y) < 10 ? y * 2 : y
];
client.send(data);
}
// mouse scroll
function handleWheel(event: any) {
disableEvent(event);
if (Math.floor(event.deltaY) === 0) return;
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
lastScrollTimeRef.current = currentTime;
const deltaY = (event.deltaY > 0 ? 1 : -1) * scrollDirection;
const data = [2, MouseEvent.Scroll, 0, 0, deltaY];
client.send(data);
}
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange);
canvas.removeEventListener('click', handleClick);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollDirection, scrollInterval]);
}
// disable default events
function disableEvent(event: any) {

View File

@@ -0,0 +1,25 @@
interface MouseMoveAbsoluteEvent {
type: 'move';
x: number;
y: number;
}
interface MouseMoveRelativeEvent {
type: 'move';
deltaX: number;
deltaY: number;
}
interface MouseButtonEvent {
type: 'mousedown' | 'mouseup';
button: number;
}
interface MouseWheelEvent {
type: 'wheel';
deltaY: number;
}
export type MouseAbsoluteEvent = MouseMoveAbsoluteEvent | MouseButtonEvent | MouseWheelEvent;
export type MouseRelativeEvent = MouseMoveRelativeEvent | MouseButtonEvent | MouseWheelEvent;

View File

@@ -12,11 +12,11 @@ import '@/assets/styles/keyboard.css';
import { ConfigProvider, Segmented, Select, theme } from 'antd';
import { useMediaQuery } from 'react-responsive';
import { getKeycode, getModifierBit } from '@/lib/keymap.ts';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
import { KeyboardCodes, ModifierCodes } from './mappings.ts';
import {
doubleKeys,
keyboardArrowsOptions,
@@ -83,6 +83,7 @@ export const VirtualKeyboard = () => {
setKeyboardLayout('default');
}, [keyboardSystem, keyboardLanguage]);
// Press key
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
@@ -97,6 +98,7 @@ export const VirtualKeyboard = () => {
sendKeydown(key);
}
// Release key
function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
return;
@@ -105,92 +107,86 @@ export const VirtualKeyboard = () => {
sendKeyup();
}
// Send all keys
function sendKeydown(key: string) {
const code = getKeyCode(key);
const code = getKeyboardCode(key);
if (!code) {
console.log('unknown code: ', key);
return;
}
const modifiers = sendModifierKeyDown();
const modifier = sendModifierKeyDown();
client.send([1, code, ...modifiers]);
send(modifier, code);
}
function getKeyCode(key: string) {
function getKeyboardCode(key: string) {
// AZERTY: swap A↔Q and Z↔W on French physical positions
if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) {
const base = key.replace('_azerty', '');
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
if (base === 'KeyA') return getKeycode('KeyQ');
if (base === 'KeyQ') return getKeycode('KeyA');
if (base === 'KeyZ') return getKeycode('KeyW');
if (base === 'KeyW') return getKeycode('KeyZ');
// all other labels use their own code
return KeyboardCodes.get(base);
return getKeycode(base);
}
if (keyboardLanguage === 'de' && key.endsWith('_qwertz')) {
const base = key.replace('_qwertz', '');
// Tausch
if (base === 'KeyZ') return KeyboardCodes.get('KeyY');
if (base === 'KeyY') return KeyboardCodes.get('KeyZ');
if (base === 'IntlBackslash') return KeyboardCodes.get('IntlBackslash_qwertz');
if (base === 'KeyZ') return getKeycode('KeyY');
if (base === 'KeyY') return getKeycode('KeyZ');
// all other labels use their own code
return KeyboardCodes.get(base);
return getKeycode(base);
}
const specialKey = specialKeyMap.get(key);
if (specialKey) {
return KeyboardCodes.get(specialKey);
return getKeycode(specialKey);
}
return KeyboardCodes.get(key);
return getKeycode(key);
}
// Release all keys
function sendKeyup() {
sendModifierKeyUp();
client.send([1, 0, 0, 0, 0, 0]);
send(0, 0);
}
// Send modifier keys
function sendModifierKeyDown() {
let ctrl = 0;
let shift = 0;
let alt = 0;
let meta = 0;
let modifier = 0;
activeModifierKeys.forEach((modifierKey) => {
const key = specialKeyMap.get(modifierKey)!;
const code = KeyboardCodes.get(key)!;
const modifier = ModifierCodes.get(key)!;
modifier |= getModifierBit(key)!;
const code = getKeycode(key)!;
if ([1, 16].includes(modifier)) {
ctrl = modifier;
} else if ([2, 32].includes(modifier)) {
shift = modifier;
} else if ([4, 64].includes(modifier)) {
alt = modifier;
} else if ([8, 128].includes(modifier)) {
meta = modifier;
}
client.send([1, code, ctrl, shift, alt, meta]);
send(modifier, code);
});
return [ctrl, shift, alt, meta];
return modifier;
}
// Release modifier keys
function sendModifierKeyUp() {
if (activeModifierKeys.length === 0) return;
activeModifierKeys.forEach(() => {
client.send([1, 0, 0, 0, 0, 0]);
send(0, 0);
});
setActiveModifierKeys([]);
}
function send(modifier: number, code: number) {
const data = new Uint8Array([MessageEvent.Keyboard, modifier, 0, code, 0, 0, 0, 0, 0]);
client.send(data);
}
function selectSystem(system: string) {
setKeyboardSystem(system);
storage.setKeyboardSystem(system);

View File

@@ -79,11 +79,11 @@ export const keyboardOptions = {
KeyY_qwertz: 'Y',
KeyZ_qwertz: 'Z',
KeyQ_qwertz: 'Q<br/>@',
KeyE_qwertz: 'E<br/>€',
BracketRight_qwertz: '+~*',
Backslash_qwertz: '#<br/>\'',
Backslash_qwertz: "#<br/>'",
IntlBackslash_qwertz: '<|>',
Comma_qwertz: ',<br/>;',
Period_qwertz: '.<br/>:',
@@ -91,11 +91,11 @@ export const keyboardOptions = {
'{controlleft_qwertz}': 'STRG',
'{altright_qwertz}': 'ALT GR',
'{controlright_qwertz}': 'STRG',
BracketLeft_qwertz: 'Ü',
Semicolon_qwertz: 'Ö',
Quote_qwertz: 'Ä',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
@@ -336,7 +336,7 @@ export const modifierKeys = [
'{altright}',
'{metaright}',
'{winright}',
// DE-spezifische Modifier-Tokens (für qwertz-Layout)
'{controlleft_qwertz}',
'{controlright_qwertz}',