Compare commits

..

14 Commits
2.3.1 ... 2.3.2

Author SHA1 Message Date
wj-xiao
329c0a0001 release: nanokvm@2.3.2 2026-01-08 19:15:52 +08:00
wenjie
c28273d1f0 Merge pull request #721 from imguoguo/ci-use-jq
ci: use jq to format json
2026-01-08 17:33:40 +08:00
Guoguo
1d22d75889 ci: use jq to format json 2026-01-08 09:16:27 +00:00
wj-xiao
6ef8bb74e3 ui: update menu bar 2026-01-08 14:45:50 +08:00
wj-xiao
0e00760474 fix: fix touchscreen scrolling 2026-01-08 14:22:22 +08:00
wenjie
d969bd68ec Merge pull request #705 from imguoguo/add-issue-tracker
feat: add issue tracker
2026-01-06 16:15:31 +08:00
Guoguo
1e5f4cb24b feat: add issue tracker 2026-01-06 08:09:51 +00:00
wj-xiao
4d3b1c835e feat: support custom keyboard shortcuts 2026-01-06 13:58:53 +08:00
wj-xiao
e3558933b1 ui: menu bar stops auto-hiding when submenu is open 2026-01-06 11:08:57 +08:00
wj-xiao
7762706200 fix: set minimum width for mouse speed component 2026-01-05 18:19:06 +08:00
wj-xiao
b66707c959 refactor: support delete request 2026-01-05 17:45:38 +08:00
wj-xiao
a3440cf867 ui: update menu bar 2026-01-05 17:37:44 +08:00
wj-xiao
1b7021d64b refactor: refactor keyboard, mouse, and WebSocket 2026-01-05 16:49:17 +08:00
BuGu
687dd3bfc4 Fix the issue of old IP residue in static IP configuration 2026-01-04 18:18:00 +08:00
55 changed files with 2505 additions and 883 deletions

64
.github/workflows/issue-tracker.yml vendored Normal file
View 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

View File

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

View File

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

View File

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

View File

@@ -7,3 +7,25 @@ type GetHidModeRsp struct {
type SetHidModeReq struct {
Mode string `validate:"required"` // normal or hid-only
}
type ShortcutKey struct {
Code string `json:"code"`
Label string `json:"label"`
}
type Shortcut struct {
ID string `json:"id"`
Keys []ShortcutKey `json:"keys"`
}
type GetShortcutsRsp struct {
Shortcuts []Shortcut `json:"shortcuts"`
}
type AddShortcutReq struct {
Keys []ShortcutKey `validate:"required"`
}
type DeleteShortcutReq struct {
ID string `validate:"required"`
}

View File

@@ -13,6 +13,10 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
package hid
import (
"encoding/json"
"errors"
"os"
"sync"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
var (
shortcutFile = "/etc/kvm/shortcuts.json"
shortcutMutex = sync.RWMutex{}
)
type ShortcutStore struct {
Shortcuts []proto.Shortcut `json:"shortcuts"`
}
func (s *Service) GetShortcuts(c *gin.Context) {
var rsp proto.Response
shortcuts, err := listShortcuts()
if err != nil {
log.Errorf("failed to get shortcuts: %v", err)
rsp.ErrRsp(c, -1, "get shortcuts failed")
return
}
rsp.OkRspWithData(c, &proto.GetShortcutsRsp{
Shortcuts: shortcuts,
})
log.Debugf("get shortcuts success, total: %d", len(shortcuts))
}
func (s *Service) AddShortcut(c *gin.Context) {
var req proto.AddShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
shortcut, err := addShortcut(req.Keys)
if err != nil {
log.Errorf("failed to add shortcut: %v", err)
rsp.ErrRsp(c, -2, "add shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("add shortcut %s", shortcut.ID)
}
func (s *Service) DeleteShortcut(c *gin.Context) {
var req proto.DeleteShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
err := deleteShortcut(req.ID)
if err != nil {
log.Errorf("failed to delete shortcut: %v", err)
rsp.ErrRsp(c, -2, "delete shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("delete shortcut %s", req.ID)
}
func loadShortcuts() (*ShortcutStore, error) {
if _, err := os.Stat(shortcutFile); os.IsNotExist(err) {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
data, err := os.ReadFile(shortcutFile)
if err != nil {
return nil, err
}
if len(data) == 0 {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
var store ShortcutStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, err
}
return &store, nil
}
func saveShortcuts(store *ShortcutStore) error {
data, err := json.Marshal(store)
if err != nil {
return err
}
return os.WriteFile(shortcutFile, data, 0644)
}
func listShortcuts() ([]proto.Shortcut, error) {
shortcutMutex.RLock()
defer shortcutMutex.RUnlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
return store.Shortcuts, nil
}
func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
shortcut := proto.Shortcut{
ID: uuid.New().String(),
Keys: keys,
}
store.Shortcuts = append(store.Shortcuts, shortcut)
if err := saveShortcuts(store); err != nil {
return nil, err
}
return &shortcut, nil
}
func deleteShortcut(id string) error {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return err
}
found := false
newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts))
for _, shortcut := range store.Shortcuts {
if shortcut.ID == id {
found = true
continue
}
newShortcuts = append(newShortcuts, shortcut)
}
if !found {
return errors.New("shortcut not found")
}
store.Shortcuts = newShortcuts
return saveShortcuts(store)
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,3 +22,24 @@ export function setHidMode(mode: string) {
};
return http.post('/api/hid/mode', data);
}
// get shortcuts
export function getShortcuts() {
return http.get('/api/hid/shortcuts');
}
// add shortcut
export function addShortcut(keys: any[]) {
const data = {
keys
};
return http.post('/api/hid/shortcut', data);
}
// delete shortcut
export function deleteShortcut(id: string) {
const data = {
id
};
return http.delete('/api/hid/shortcut', data);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,14 +76,23 @@ const en = {
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Read from Clipboard',
clipboardPermissionDenied:
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German',
dropdownRussian: 'Russian'
dropdownRussian: 'Russian',
shortcut: {
title: 'Shortcuts',
custom: 'Custom',
capture: 'Click here to capture shortcut',
clear: 'Clear',
save: 'Save',
captureTips:
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
enterFullScreen: 'Toggle full-screen mode.'
}
},
mouse: {
title: 'Mouse',

View File

@@ -73,7 +73,15 @@ const zh = {
placeholder: '请输入内容',
submit: '确定',
virtual: '虚拟键盘',
ctrlaltdel: 'Ctrl+Alt+Del'
shortcut: {
title: '快捷键',
custom: '自定义',
capture: '点击此处捕获快捷键',
clear: '清空',
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
}
},
mouse: {
title: '鼠标',
@@ -206,7 +214,7 @@ const zh = {
languageDesc: '选择界面语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: '网站标题',
webTitle: '网页标题',
webTitleDesc: '自定义网站页面标题'
},
device: {

View File

@@ -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('');

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -1,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 <></>;

View File

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

View File

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

View File

@@ -1,19 +1,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>
);

View File

@@ -1,31 +0,0 @@
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { OctagonMinus } from 'lucide-react';
import { KeyboardCodes, ModifierCodes } from '@/pages/desktop/keyboard/mappings.ts';
import { client } from '@/lib/websocket.ts';
export const CtrlAltDel = () => {
const { t } = useTranslation();
function sendCtrlAltDel() {
const ctrl = ModifierCodes.get('ControlLeft')!;
const alt = ModifierCodes.get('AltLeft')!;
const del = KeyboardCodes.get('Delete')!;
client.send([1, del, ctrl, 0, alt, 0]);
client.send([1, 0, 0, 0, 0, 0]);
};
return (
<div
className={clsx(
"flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
)}
onClick={sendCtrlAltDel}
>
<OctagonMinus size={18} />
<span>{t('keyboard.ctrlaltdel')}</span>
</div>
);
}

View File

@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
import { MenuItem } from '@/components/menu-item.tsx';
import { CtrlAltDel } from './ctrl-alt-del.tsx';
import { Paste } from './paste.tsx';
import { Shortcuts } from './shortcuts';
import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
@@ -15,11 +15,11 @@ export const Keyboard = () => {
title={t('keyboard.title')}
icon={<KeyboardIcon size={18} />}
content={
<>
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<CtrlAltDel />
</>
<Shortcuts />
</div>
}
/>
);

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import { CommandIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Recorder } from './recorder.tsx';
import { Shortcut } from './shortcut.tsx';
import type { Shortcut as ShortcutInterface } from './types.ts';
export const Shortcuts = () => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [customShortcuts, setCustomShortcuts] = useState<ShortcutInterface[]>([]);
const defaultShortcuts: ShortcutInterface[] = [
{
keys: [
{ code: 'MetaLeft', label: 'Win' },
{ code: 'Tab', label: 'Tab' }
]
},
{
keys: [
{ code: 'ControlLeft', label: 'Ctrl' },
{ code: 'AltLeft', label: 'Alt' },
{ code: 'Delete', label: '⌫' }
]
}
];
useEffect(() => {
getShortcuts();
}, [isOpen]);
async function getShortcuts() {
try {
const rsp = await api.getShortcuts();
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setCustomShortcuts(rsp.data.shortcuts);
} catch (err) {
console.log(err);
}
}
async function addShortcut(shortcut: ShortcutInterface) {
try {
const rsp = await api.addShortcut(shortcut.keys);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
await getShortcuts();
} catch (err) {
console.log(err);
}
}
async function delShortcut(shortcut: ShortcutInterface) {
try {
if (!shortcut.id) return;
const rsp = await api.deleteShortcut(shortcut.id);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
await getShortcuts();
} catch (err) {
console.log(err);
}
}
function handleOpenChange(open: boolean) {
if (open) {
setIsOpen(true);
return;
}
if (isRecording) {
return;
}
setIsOpen(false);
}
const content = (
<ScrollArea className="max-w-[400px] [&>[data-radix-scroll-area-viewport]]:max-h-[350px]">
{/* custom shortcuts */}
{customShortcuts.length > 0 && (
<>
{customShortcuts.map((shortcut) => (
<Shortcut key={shortcut.id} shortcut={shortcut}></Shortcut>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
</>
)}
{/* default shortcuts */}
{defaultShortcuts.map((shortcut, index) => (
<Shortcut key={index} shortcut={shortcut}></Shortcut>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
<Recorder
shortcuts={customShortcuts}
addShortcut={addShortcut}
delShortcut={delShortcut}
setIsRecording={setIsRecording}
/>
</ScrollArea>
);
return (
<Popover
content={content}
trigger="hover"
placement="rightTop"
align={{ offset: [14, 0] }}
open={isOpen}
onOpenChange={handleOpenChange}
arrow={false}
>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70">
<CommandIcon size={18} />
<span>{t('keyboard.shortcut.title')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,262 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Divider, Input, InputRef, Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { KeyboardIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isModifier } from '@/lib/keymap.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
import { ScrollArea } from '@/components/ui/scroll-area.tsx';
import { KeyInfo, Shortcut } from './types.ts';
const MAX_KEYS = 6;
const SpecialKeyMap: Record<string, string> = {
Space: 'Space',
Backspace: '⌫',
Enter: '↵',
Tab: 'Tab',
CapsLock: 'Caps',
Escape: 'Esc',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Delete: 'Del',
Insert: 'Ins',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn'
};
const PunctuationMap: Record<string, string> = {
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Backquote: '`',
Comma: ',',
Period: '.',
Slash: '/'
};
interface RecorderProps {
shortcuts: Shortcut[];
addShortcut: (shortcut: Shortcut) => void;
delShortcut: (shortcut: Shortcut) => void;
setIsRecording: (isRecording: boolean) => void;
}
export const Recorder = ({
shortcuts,
addShortcut,
delShortcut,
setIsRecording
}: RecorderProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [shortcutLabel, setShortcutLabel] = useState('');
const inputRef = useRef<InputRef | null>(null);
const recordedKeysRef = useRef<KeyInfo[]>([]);
useEffect(() => {
setIsKeyboardEnable(!isModalOpen);
setIsRecording(isModalOpen);
if (!isModalOpen) return;
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => {
clearTimeout(timer);
};
}, [isModalOpen]);
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
const { key, code } = event;
const isRecorded = recordedKeysRef.current.some((k) => k.code === code);
if (isRecorded) return;
if (recordedKeysRef.current.length >= MAX_KEYS) return;
const keyInfo: KeyInfo = {
code: code,
label: formatKeyDisplay(key, code)
};
setShortcutLabel((prev) => (prev ? `${prev} + ${keyInfo.label}` : keyInfo.label));
recordedKeysRef.current.push(keyInfo);
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isFocused]);
const formatKeyDisplay = (key: string, code: string): string => {
if (isModifier(code)) {
if (code.startsWith('Control')) {
return 'Ctrl';
}
if (code.startsWith('Shift')) {
return 'Shift';
}
if (code.startsWith('Alt')) {
return 'Alt';
}
if (code.startsWith('Meta')) {
return 'Win';
}
}
if (code.startsWith('Digit')) {
return code.replace('Digit', '');
}
if (code.startsWith('Key')) {
return code.replace('Key', '');
}
if (code.startsWith('Numpad')) {
const numpadKey = code.replace('Numpad', '');
return `Num${numpadKey}`;
}
if (code.startsWith('F') && /^F\d+$/.test(code)) {
return code; // F1, F2, etc.
}
if (SpecialKeyMap[code]) {
return SpecialKeyMap[code];
}
if (PunctuationMap[code]) {
return PunctuationMap[code];
}
return key.toUpperCase();
};
function saveShortcut() {
if (recordedKeysRef.current.length > 0) {
addShortcut({ keys: recordedKeysRef.current });
}
clearShortcut();
}
function clearShortcut() {
setShortcutLabel('');
recordedKeysRef.current = [];
}
function closeModal() {
clearShortcut();
setIsModalOpen(false);
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/lock
navigator.keyboard?.lock();
} else {
document.exitFullscreen();
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/unlock
navigator.keyboard?.unlock();
}
}
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/60"
onClick={() => setIsModalOpen(true)}
>
<span>{t('keyboard.shortcut.custom')}</span>
</div>
<Modal
width={500}
title={t('keyboard.shortcut.title')}
keyboard={false}
footer={null}
open={isModalOpen}
onCancel={closeModal}
>
<div>
<span className="text-neutral-500">{t('keyboard.shortcut.captureTips')}</span>
<a className="px-1 text-blue-500/80" onClick={toggleFullscreen}>
{t('keyboard.shortcut.enterFullScreen')}
</a>
</div>
<div className="flex items-center space-x-0.5 py-6">
<Input
ref={inputRef}
placeholder={t('keyboard.shortcut.capture')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
value={shortcutLabel}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<Button onClick={clearShortcut}>{t('keyboard.shortcut.clear')}</Button>
</div>
<div className="flex w-full justify-center pb-3">
<Button type="primary" className="min-w-24" onClick={saveShortcut}>
{t('keyboard.shortcut.save')}
</Button>
</div>
{shortcuts.length > 0 && (
<>
<Divider />
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[300px]">
{shortcuts.map((shortcut) => (
<div
key={shortcut.id}
className="flex items-center justify-between rounded p-2 hover:bg-neutral-700/50"
>
<div className="flex items-center space-x-1">
{shortcut.keys.map((key, index) => (
<KbdGroup key={index}>
<Kbd>{key.label}</Kbd>
</KbdGroup>
))}
</div>
<div
className="flex size-[20px] cursor-pointer items-center justify-center rounded-sm text-neutral-500 hover:text-red-500"
onClick={() => delShortcut(shortcut)}
>
<Trash2Icon size={16} />
</div>
</div>
))}
</ScrollArea>
</>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { KeyboardReport } from '@/lib/keyboard.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
import type { Shortcut as ShortcutInterface } from './types.ts';
type ShortcutProps = {
shortcut: ShortcutInterface;
};
export const Shortcut = ({ shortcut }: ShortcutProps) => {
const [isLoading, setIsLoading] = useState(false);
async function sendShortcut() {
const keyboard = new KeyboardReport();
shortcut.keys.forEach((key) => {
const report = keyboard.keyDown(key.code);
send(report);
});
const report = keyboard.reset();
send(report);
}
function send(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
client.send(data);
}
async function handleClick(): Promise<void> {
if (isLoading) return;
setIsLoading(true);
try {
await sendShortcut();
} catch (err) {
console.log(err);
} finally {
setIsLoading(false);
}
}
return (
<div
className="flex h-[32px] w-full cursor-pointer items-center space-x-1 rounded px-3 hover:bg-neutral-700/30"
onClick={handleClick}
>
{shortcut.keys.map((key, index) => (
<KbdGroup key={index}>
<Kbd>{key.label}</Kbd>
</KbdGroup>
))}
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,11 @@ import '@/assets/styles/keyboard.css';
import { ConfigProvider, Segmented, Select, theme } from 'antd';
import { useMediaQuery } from 'react-responsive';
import { getKeycode, getModifierBit } from '@/lib/keymap.ts';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
import { KeyboardCodes, ModifierCodes } from './mappings.ts';
import {
doubleKeys,
keyboardArrowsOptions,
@@ -83,6 +83,7 @@ export const VirtualKeyboard = () => {
setKeyboardLayout('default');
}, [keyboardSystem, keyboardLanguage]);
// Press key
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
@@ -97,6 +98,7 @@ export const VirtualKeyboard = () => {
sendKeydown(key);
}
// Release key
function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
return;
@@ -105,92 +107,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);