mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-11 18:36:19 -06:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
329c0a0001 | ||
|
|
c28273d1f0 | ||
|
|
1d22d75889 | ||
|
|
6ef8bb74e3 | ||
|
|
0e00760474 | ||
|
|
d969bd68ec | ||
|
|
1e5f4cb24b | ||
|
|
4d3b1c835e | ||
|
|
e3558933b1 | ||
|
|
7762706200 | ||
|
|
b66707c959 | ||
|
|
a3440cf867 | ||
|
|
1b7021d64b | ||
|
|
687dd3bfc4 |
64
.github/workflows/issue-tracker.yml
vendored
Normal file
64
.github/workflows/issue-tracker.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Issue Tracker
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
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 url "${{ github.event.issue.html_url }}" \
|
||||
--arg user_login "${{ github.event.issue.user.login }}" \
|
||||
--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,
|
||||
url: $url,
|
||||
user: { login: $user_login },
|
||||
labels: $labels,
|
||||
assignees: $assignees
|
||||
}
|
||||
}')
|
||||
|
||||
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
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
||||
## 2.3.2 (2025-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 {
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -73,7 +73,15 @@ const zh = {
|
||||
placeholder: '请输入内容',
|
||||
submit: '确定',
|
||||
virtual: '虚拟键盘',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
shortcut: {
|
||||
title: '快捷键',
|
||||
custom: '自定义',
|
||||
capture: '点击此处捕获快捷键',
|
||||
clear: '清空',
|
||||
save: '保存',
|
||||
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
|
||||
enterFullScreen: '切换全屏模式。'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: '鼠标',
|
||||
@@ -206,7 +214,7 @@ const zh = {
|
||||
languageDesc: '选择界面语言',
|
||||
menuBar: '菜单栏',
|
||||
menuBarDesc: '是否在菜单栏中显示图标',
|
||||
webTitle: '网站标题',
|
||||
webTitle: '网页标题',
|
||||
webTitleDesc: '自定义网站页面标题'
|
||||
},
|
||||
device: {
|
||||
|
||||
@@ -3,5 +3,8 @@ 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('');
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
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,98 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
import { KeyboardCodes } from './mappings.ts';
|
||||
import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
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>());
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Key down event
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
if (pressedKeys.current.has(code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
handleKeyEvent({ type: 'keydown', code });
|
||||
}
|
||||
|
||||
// Key up event
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
pressedKeys.current.delete(code);
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
const report = keyboardRef.current.reset();
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [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,19 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
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 { useAtom, 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, submenuOpenCountAtom } from '@/jotai/settings.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';
|
||||
@@ -21,80 +21,158 @@ import { Settings } from './settings';
|
||||
import { Terminal } from './terminal';
|
||||
import { Wol } from './wol';
|
||||
|
||||
const HIDE_TIMEOUT = 8000;
|
||||
|
||||
export const Menu = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
|
||||
const [isMenuMoved, setIsMenuMoved] = useState(false);
|
||||
const [isMenuHovered, setIsMenuHovered] = useState(false);
|
||||
const [isMenuHidden, setIsMenuHidden] = useState(false);
|
||||
const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
|
||||
const nodeRef = useRef<any>(null);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// init menu
|
||||
useEffect(() => {
|
||||
// disabled menu items
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
|
||||
// react-draggable bounds
|
||||
const handleResize = () => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
startCountdown();
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
stopCountdown();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// handle menu bounds
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [menuDisabledItems, isMenuExpanded]);
|
||||
|
||||
// handle hide timer
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0 && !isMenuHovered) {
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
stopCountdown();
|
||||
}, [submenuOpenCount, isMenuHovered]);
|
||||
|
||||
function handleResize() {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsMenuHidden(true);
|
||||
}, HIDE_TIMEOUT);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoved(_e: DraggableEvent, data: DraggableData) {
|
||||
if (data.x === 0 && data.y === 0) return;
|
||||
if (isMenuMoved) return;
|
||||
|
||||
setIsMenuMoved(true);
|
||||
}
|
||||
|
||||
function handleHovered(hovered: boolean) {
|
||||
setIsMenuHovered(hovered);
|
||||
if (hovered) {
|
||||
setIsMenuHidden(false);
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled(item: string) {
|
||||
return !menuDisabledItems.includes(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds={bounds}
|
||||
bounds={menuBounds}
|
||||
handle="strong"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
onStop={handleMoved}
|
||||
>
|
||||
<div ref={nodeRef} className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2"
|
||||
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 transition-all duration-300',
|
||||
'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 +180,13 @@ export const Menu = () => {
|
||||
)}
|
||||
|
||||
<Settings />
|
||||
<Fullscreen />
|
||||
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="mr-1 flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEnabled('fullscreen') && <Fullscreen />}
|
||||
{isEnabled('collapse') && <Collapse toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMenuOpen && (
|
||||
<Tooltip title={t('menu.expand')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex h-[30px] w-[32px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-700 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Menubar expand button */}
|
||||
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
DiscIcon,
|
||||
DownloadIcon,
|
||||
FileJsonIcon,
|
||||
MaximizeIcon,
|
||||
NetworkIcon,
|
||||
PowerIcon,
|
||||
TerminalSquareIcon
|
||||
TerminalSquareIcon,
|
||||
XIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -19,12 +21,14 @@ export const MenuBar = () => {
|
||||
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) {
|
||||
@@ -48,10 +52,13 @@ export const MenuBar = () => {
|
||||
<div className="mt-8 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)}
|
||||
|
||||
@@ -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,87 @@ 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');
|
||||
if (base === 'IntlBackslash') return getKeycode('IntlBackslash_qwertz');
|
||||
// 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);
|
||||
Reference in New Issue
Block a user