mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-11 10:29:19 -06:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcc4c3747 | ||
|
|
0d2c52adc3 | ||
|
|
af256e7f92 | ||
|
|
84830421ee | ||
|
|
9b278cdb0e | ||
|
|
9b40a5cf86 | ||
|
|
b8d980be33 | ||
|
|
195037af87 | ||
|
|
c98000069a | ||
|
|
2bd277b57d | ||
|
|
4852ed813b | ||
|
|
fb08df9e4e | ||
|
|
64f3b49e21 | ||
|
|
329c0a0001 | ||
|
|
c28273d1f0 | ||
|
|
1d22d75889 | ||
|
|
6ef8bb74e3 | ||
|
|
0e00760474 | ||
|
|
d969bd68ec | ||
|
|
1e5f4cb24b | ||
|
|
4d3b1c835e | ||
|
|
e3558933b1 | ||
|
|
7762706200 | ||
|
|
b66707c959 | ||
|
|
a3440cf867 | ||
|
|
1b7021d64b | ||
|
|
687dd3bfc4 |
68
.github/workflows/issue-tracker.yml
vendored
Normal file
68
.github/workflows/issue-tracker.yml
vendored
Normal 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
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,3 +1,44 @@
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -7,3 +7,25 @@ type GetHidModeRsp struct {
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
|
||||
type ShortcutKey struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID string `json:"id"`
|
||||
Keys []ShortcutKey `json:"keys"`
|
||||
}
|
||||
|
||||
type GetShortcutsRsp struct {
|
||||
Shortcuts []Shortcut `json:"shortcuts"`
|
||||
}
|
||||
|
||||
type AddShortcutReq struct {
|
||||
Keys []ShortcutKey `validate:"required"`
|
||||
}
|
||||
|
||||
type DeleteShortcutReq struct {
|
||||
ID string `validate:"required"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ func hidRouter(r *gin.Engine) {
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
api.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
172
server/service/hid/shortcut.go
Normal file
172
server/service/hid/shortcut.go
Normal 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)
|
||||
}
|
||||
109
server/service/ws/client.go
Normal file
109
server/service/ws/client.go
Normal 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)
|
||||
}
|
||||
46
server/service/ws/manager.go
Normal file
46
server/service/ws/manager.go
Normal 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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package ws
|
||||
|
||||
type Stream struct {
|
||||
Type string `json:"type"`
|
||||
State int `json:"state"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
29
server/service/ws/types.go
Normal file
29
server/service/ws/types.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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
32
web/eslint.config.js
Normal 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
|
||||
)
|
||||
@@ -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
251
web/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -22,3 +22,24 @@ export function setHidMode(mode: string) {
|
||||
};
|
||||
return http.post('/api/hid/mode', data);
|
||||
}
|
||||
|
||||
// get shortcuts
|
||||
export function getShortcuts() {
|
||||
return http.get('/api/hid/shortcuts');
|
||||
}
|
||||
|
||||
// add shortcut
|
||||
export function addShortcut(keys: any[]) {
|
||||
const data = {
|
||||
keys
|
||||
};
|
||||
return http.post('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
// delete shortcut
|
||||
export function deleteShortcut(id: string) {
|
||||
const data = {
|
||||
id
|
||||
};
|
||||
return http.delete('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
28
web/src/components/ui/kbd.tsx
Normal file
28
web/src/components/ui/kbd.tsx
Normal 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 };
|
||||
55
web/src/hooks/useMenuBounds.ts
Normal file
55
web/src/hooks/useMenuBounds.ts
Normal 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;
|
||||
}
|
||||
129
web/src/hooks/useMenuVisibility.ts
Normal file
129
web/src/hooks/useMenuVisibility.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
isInvisible: 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(() => {
|
||||
if (menuDisplayMode === 'off') {
|
||||
setIsMenuExpanded(false);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'always') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'auto') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
startCountdown();
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
const isInvisible = menuDisplayMode === 'off' && !isMenuExpanded;
|
||||
|
||||
return {
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isMenuMoved,
|
||||
isInvisible,
|
||||
isInitialized,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
};
|
||||
}
|
||||
@@ -76,14 +76,23 @@ const en = {
|
||||
placeholder: 'Please input',
|
||||
submit: 'Submit',
|
||||
virtual: 'Keyboard',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
readClipboard: 'Read from Clipboard',
|
||||
clipboardPermissionDenied:
|
||||
'Clipboard permission denied. Please allow clipboard access in your browser.',
|
||||
clipboardReadError: 'Failed to read clipboard',
|
||||
dropdownEnglish: 'English',
|
||||
dropdownGerman: 'German',
|
||||
dropdownRussian: 'Russian'
|
||||
dropdownRussian: 'Russian',
|
||||
shortcut: {
|
||||
title: 'Shortcuts',
|
||||
custom: 'Custom',
|
||||
capture: 'Click here to capture shortcut',
|
||||
clear: 'Clear',
|
||||
save: 'Save',
|
||||
captureTips:
|
||||
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
|
||||
enterFullScreen: 'Toggle full-screen mode.'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mouse',
|
||||
@@ -234,10 +243,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',
|
||||
|
||||
@@ -217,6 +217,7 @@ const ko = {
|
||||
title: '디자인',
|
||||
display: '표시',
|
||||
language: '언어',
|
||||
languageDesc: '인터페이스 언어를 선택하세요',
|
||||
menuBar: '메뉴 바',
|
||||
menuBarDesc: '메뉴 바에 아이콘을 표시',
|
||||
webTitle: '웹 제목',
|
||||
@@ -336,7 +337,14 @@ const ko = {
|
||||
cancel: '취소',
|
||||
preview: '미리보기 업데이트',
|
||||
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
|
||||
offline: {
|
||||
title: '오프라인 업데이트',
|
||||
desc: '로컬 설치 패키지를 통한 업데이트',
|
||||
upload: '업로드',
|
||||
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
|
||||
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: '계정',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -73,7 +73,15 @@ const zh = {
|
||||
placeholder: '请输入内容',
|
||||
submit: '确定',
|
||||
virtual: '虚拟键盘',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
shortcut: {
|
||||
title: '快捷键',
|
||||
custom: '自定义',
|
||||
capture: '点击此处捕获快捷键',
|
||||
clear: '清空',
|
||||
save: '保存',
|
||||
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
|
||||
enterFullScreen: '切换全屏模式。'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: '鼠标',
|
||||
@@ -204,10 +212,18 @@ const zh = {
|
||||
display: '显示',
|
||||
language: '语言',
|
||||
languageDesc: '选择界面语言',
|
||||
menuBar: '菜单栏',
|
||||
menuBarDesc: '是否在菜单栏中显示图标',
|
||||
webTitle: '网站标题',
|
||||
webTitleDesc: '自定义网站页面标题'
|
||||
webTitle: '网页标题',
|
||||
webTitleDesc: '自定义网站页面标题',
|
||||
menuBar: {
|
||||
title: '菜单栏',
|
||||
mode: '显示方式',
|
||||
modeDesc: '菜单栏在屏幕上的显示方式',
|
||||
modeOff: '关闭',
|
||||
modeAuto: '自动隐藏',
|
||||
modeAlways: '始终显示',
|
||||
icons: '菜单图标',
|
||||
iconsDesc: '是否在菜单栏中显示子菜单图标'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '设备',
|
||||
|
||||
@@ -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');
|
||||
|
||||
56
web/src/lib/browser.ts
Normal file
56
web/src/lib/browser.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface BrowserInfo {
|
||||
os: string;
|
||||
isChrome: boolean;
|
||||
}
|
||||
|
||||
export function getOperatingSystem(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const platformSource =
|
||||
// @ts-expect-error check userAgentData.platform
|
||||
'userAgentData' in navigator ? navigator.userAgentData.platform : navigator.platform;
|
||||
|
||||
if (!platformSource) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const platform = platformSource.toLowerCase();
|
||||
|
||||
if (platform.startsWith('win')) return 'Windows';
|
||||
if (platform.startsWith('mac')) return 'macOS';
|
||||
if (platform.startsWith('linux')) return 'Linux';
|
||||
|
||||
return platformSource;
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
@@ -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
65
web/src/lib/keyboard.ts
Normal 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
269
web/src/lib/keymap.ts
Normal 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];
|
||||
}
|
||||
@@ -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
138
web/src/lib/mouse.ts
Normal 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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,102 +1,177 @@
|
||||
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';
|
||||
interface AltGrState {
|
||||
active: boolean;
|
||||
ctrlLeftTimestamp: number;
|
||||
}
|
||||
|
||||
const ALTGR_THRESHOLD_MS = 10;
|
||||
|
||||
export const Keyboard = () => {
|
||||
const pressedKeys = useRef<Set<string>>(new Set());
|
||||
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
const keyboardRef = useRef(new KeyboardReport());
|
||||
const pressedKeys = useRef(new Set<string>());
|
||||
const altGrState = useRef<AltGrState | null>(null);
|
||||
const isComposing = useRef(false);
|
||||
|
||||
// listen keyboard events
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('blur', releaseAllKeys);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// press button
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
disableEvent(event);
|
||||
|
||||
if (!pressedKeys.current.has(event.code)) {
|
||||
pressedKeys.current.add(event.code);
|
||||
}
|
||||
|
||||
sendKeyDown(event);
|
||||
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
|
||||
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendKeyUp();
|
||||
pressedKeys.current.clear();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseAllKeys();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', releaseAllKeys);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function sendKeyDown(event: KeyboardEvent) {
|
||||
const code = KeyboardCodes.get(event.code);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', event.code);
|
||||
if (!isKeyboardEnabled) {
|
||||
releaseKeys();
|
||||
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;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('compositionstart', handleCompositionStart);
|
||||
document.addEventListener('compositionend', handleCompositionEnd);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Key down event
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
// Skip during IME composition
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
if (pressedKeys.current.has(code)) return;
|
||||
|
||||
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
|
||||
if (altGrState.current) {
|
||||
if (code === 'ControlLeft') {
|
||||
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
|
||||
} else if (code === 'AltRight') {
|
||||
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
|
||||
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
|
||||
altGrState.current.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
handleKeyEvent({ type: 'keydown', code });
|
||||
}
|
||||
|
||||
// Key up event
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
|
||||
// Handle AltGr state for Windows
|
||||
if (altGrState.current?.active) {
|
||||
if (code === 'ControlLeft') return;
|
||||
|
||||
if (code === 'AltRight') {
|
||||
altGrState.current.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compatible with macOS's command key combinations
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
const keysToRelease: string[] = [];
|
||||
pressedKeys.current.forEach((pressedCode) => {
|
||||
if (!isModifier(pressedCode)) {
|
||||
keysToRelease.push(pressedCode);
|
||||
}
|
||||
});
|
||||
|
||||
for (const key of keysToRelease) {
|
||||
handleKeyEvent({ type: 'keyup', code: key });
|
||||
pressedKeys.current.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
pressedKeys.current.delete(code);
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
}
|
||||
|
||||
// Composition start event
|
||||
function handleCompositionStart() {
|
||||
isComposing.current = true;
|
||||
}
|
||||
|
||||
// Composition end event
|
||||
function handleCompositionEnd() {
|
||||
isComposing.current = false;
|
||||
}
|
||||
|
||||
// Release all keys when window loses focus
|
||||
function handleBlur() {
|
||||
releaseKeys();
|
||||
}
|
||||
|
||||
// Release all keys before window closes
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseKeys();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
];
|
||||
// Release all keys
|
||||
function releaseKeys() {
|
||||
pressedKeys.current.forEach((code) => {
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
});
|
||||
|
||||
client.send([1, code, ...modifiers]);
|
||||
pressedKeys.current.clear();
|
||||
|
||||
// Reset AltGr state
|
||||
if (altGrState.current) {
|
||||
altGrState.current.active = false;
|
||||
altGrState.current.ctrlLeftTimestamp = 0;
|
||||
}
|
||||
|
||||
const report = keyboardRef.current.reset();
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
return () => {
|
||||
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]);
|
||||
|
||||
// Keyboard handler
|
||||
function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }) {
|
||||
const kb = keyboardRef.current;
|
||||
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
function sendKeyUp() {
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// disable the default keyboard events
|
||||
function disableEvent(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Send keyboard report
|
||||
function sendReport(report: Uint8Array) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
|
||||
@@ -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]
|
||||
]);
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 { menuDisabledItemsAtom, menuDisplayModeAtom } 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,88 @@ 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 menuDisplayMode = useAtomValue(menuDisplayModeAtom);
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
|
||||
const nodeRef = useRef<any>(null);
|
||||
const {
|
||||
isInitialized,
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isInvisible,
|
||||
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',
|
||||
isInvisible && 'invisible'
|
||||
)}
|
||||
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 +112,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 && menuDisplayMode !== 'off' && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { CtrlAltDel } from './ctrl-alt-del.tsx';
|
||||
import { Paste } from './paste.tsx';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
|
||||
export const Keyboard = () => {
|
||||
@@ -15,11 +15,11 @@ export const Keyboard = () => {
|
||||
title={t('keyboard.title')}
|
||||
icon={<KeyboardIcon size={18} />}
|
||||
content={
|
||||
<>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<CtrlAltDel />
|
||||
</>
|
||||
<Shortcuts />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal file
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal file
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal file
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface KeyInfo {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Shortcut {
|
||||
id?: string;
|
||||
keys: KeyInfo[];
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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={{
|
||||
|
||||
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal file
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
web/src/pages/desktop/menu/operations/expand.tsx
Normal file
27
web/src/pages/desktop/menu/operations/expand.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
web/src/pages/desktop/menu/operations/index.tsx
Normal file
2
web/src/pages/desktop/menu/operations/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Collapse } from './collapse.tsx';
|
||||
export { Expand } from './expand.tsx';
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
38
web/src/pages/desktop/menu/settings/appearance/menu-mode.tsx
Normal file
38
web/src/pages/desktop/menu/settings/appearance/menu-mode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
25
web/src/pages/desktop/mouse/types.ts
Normal file
25
web/src/pages/desktop/mouse/types.ts
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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}',
|
||||
Reference in New Issue
Block a user