mirror of https://github.com/jetkvm/kvm.git
Compare commits
14 Commits
9b78cf9f0b
...
84f487a17f
| Author | SHA1 | Date |
|---|---|---|
|
|
84f487a17f | |
|
|
ea068414dc | |
|
|
8d1a66806c | |
|
|
6202e3cafa | |
|
|
1e2cee7060 | |
|
|
a86b516f9a | |
|
|
137d22b0b3 | |
|
|
d6de9668bd | |
|
|
a667aefc96 | |
|
|
7014560b41 | |
|
|
d7c8abbb11 | |
|
|
024cbb8fb1 | |
|
|
f58e5476bf | |
|
|
4b0818502c |
14
Makefile
14
Makefile
|
|
@ -62,7 +62,19 @@ build_dev_test: build_test2json build_gotestsum
|
||||||
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
|
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device && \
|
||||||
|
find ../static/assets \
|
||||||
|
-type f \
|
||||||
|
\( -name '*.js' \
|
||||||
|
-o -name '*.css' \
|
||||||
|
-o -name '*.png' \
|
||||||
|
-o -name '*.jpg' \
|
||||||
|
-o -name '*.jpeg' \
|
||||||
|
-o -name '*.gif' \
|
||||||
|
-o -name '*.webp' \
|
||||||
|
-o -name '*.woff2' \
|
||||||
|
\) \
|
||||||
|
-exec sh -c 'gzip -9 -kfv {}' \;
|
||||||
|
|
||||||
dev_release: frontend build_dev
|
dev_release: frontend build_dev
|
||||||
@echo "Uploading release..."
|
@echo "Uploading release..."
|
||||||
|
|
|
||||||
4
cloud.go
4
cloud.go
|
|
@ -475,6 +475,10 @@ func handleSessionRequest(
|
||||||
|
|
||||||
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
||||||
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
||||||
|
|
||||||
|
// Cancel any ongoing keyboard report multi when session changes
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
currentSession = session
|
currentSession = session
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -81,6 +81,7 @@ require (
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/vearutop/statigz v1.5.0 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/arch v0.18.0 // indirect
|
golang.org/x/arch v0.18.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -171,6 +171,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
|
||||||
|
github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
|
|
|
||||||
25
hidrpc.go
25
hidrpc.go
|
|
@ -1,7 +1,9 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/hidrpc"
|
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||||
|
|
@ -29,6 +31,16 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
session.reportHidRPCKeysDownState(*keysDownState)
|
session.reportHidRPCKeysDownState(*keysDownState)
|
||||||
}
|
}
|
||||||
rpcErr = err
|
rpcErr = err
|
||||||
|
case hidrpc.TypeKeyboardMacroReport:
|
||||||
|
keyboardMacroReport, err := message.KeyboardMacroReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Macro)
|
||||||
|
case hidrpc.TypeCancelKeyboardMacroReport:
|
||||||
|
rpcCancelKeyboardMacro()
|
||||||
|
return
|
||||||
case hidrpc.TypePointerReport:
|
case hidrpc.TypePointerReport:
|
||||||
pointerReport, err := message.PointerReport()
|
pointerReport, err := message.PointerReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -128,6 +140,8 @@ func reportHidRPC(params any, session *Session) {
|
||||||
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
|
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
|
||||||
case usbgadget.KeysDownState:
|
case usbgadget.KeysDownState:
|
||||||
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
|
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
|
||||||
|
case hidrpc.KeyboardMacroStateReport:
|
||||||
|
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unknown HID RPC message type: %T", params)
|
err = fmt.Errorf("unknown HID RPC message type: %T", params)
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +157,10 @@ func reportHidRPC(params any, session *Session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := session.HidChannel.Send(message); err != nil {
|
if err := session.HidChannel.Send(message); err != nil {
|
||||||
|
if errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
logger.Debug().Err(err).Msg("HID RPC channel closed, skipping reportHidRPC")
|
||||||
|
return
|
||||||
|
}
|
||||||
logger.Warn().Err(err).Msg("failed to send HID RPC message")
|
logger.Warn().Err(err).Msg("failed to send HID RPC message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,3 +178,10 @@ func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
|
||||||
}
|
}
|
||||||
reportHidRPC(state, s)
|
reportHidRPC(state, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroStateReport) {
|
||||||
|
if !s.hidRPCAvailable {
|
||||||
|
writeJSONRPCEvent("keyboardMacroState", state, s)
|
||||||
|
}
|
||||||
|
reportHidRPC(state, s)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,17 @@ import (
|
||||||
type MessageType byte
|
type MessageType byte
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TypeHandshake MessageType = 0x01
|
TypeHandshake MessageType = 0x01
|
||||||
TypeKeyboardReport MessageType = 0x02
|
TypeKeyboardReport MessageType = 0x02
|
||||||
TypePointerReport MessageType = 0x03
|
TypePointerReport MessageType = 0x03
|
||||||
TypeWheelReport MessageType = 0x04
|
TypeWheelReport MessageType = 0x04
|
||||||
TypeKeypressReport MessageType = 0x05
|
TypeKeypressReport MessageType = 0x05
|
||||||
TypeMouseReport MessageType = 0x06
|
TypeMouseReport MessageType = 0x06
|
||||||
TypeKeyboardLedState MessageType = 0x32
|
TypeKeyboardMacroReport MessageType = 0x07
|
||||||
TypeKeydownState MessageType = 0x33
|
TypeCancelKeyboardMacroReport MessageType = 0x08
|
||||||
|
TypeKeyboardLedState MessageType = 0x32
|
||||||
|
TypeKeydownState MessageType = 0x33
|
||||||
|
TypeKeyboardMacroStateReport MessageType = 0x34
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -29,10 +32,13 @@ func GetQueueIndex(messageType MessageType) int {
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case TypeHandshake:
|
case TypeHandshake:
|
||||||
return 0
|
return 0
|
||||||
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState:
|
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroStateReport:
|
||||||
return 1
|
return 1
|
||||||
case TypePointerReport, TypeMouseReport, TypeWheelReport:
|
case TypePointerReport, TypeMouseReport, TypeWheelReport:
|
||||||
return 2
|
return 2
|
||||||
|
// we don't want to block the queue for this message
|
||||||
|
case TypeCancelKeyboardMacroReport:
|
||||||
|
return 3
|
||||||
default:
|
default:
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
|
|
@ -98,3 +104,19 @@ func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
|
||||||
d: data,
|
d: data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewKeyboardMacroStateMessage creates a new keyboard macro state message.
|
||||||
|
func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
|
||||||
|
data := make([]byte, 2)
|
||||||
|
if state {
|
||||||
|
data[0] = 1
|
||||||
|
}
|
||||||
|
if isPaste {
|
||||||
|
data[1] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Message{
|
||||||
|
t: TypeKeyboardMacroStateReport,
|
||||||
|
d: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package hidrpc
|
package hidrpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -43,6 +44,11 @@ func (m *Message) String() string {
|
||||||
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
|
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
|
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
|
||||||
|
case TypeKeyboardMacroReport:
|
||||||
|
if len(m.d) < 5 {
|
||||||
|
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
|
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +90,51 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Macro ..
|
||||||
|
type KeyboardMacro struct {
|
||||||
|
Modifier byte // 1 byte
|
||||||
|
Keys []byte // 6 bytes, to make things easier, the keys length is fixed to 6
|
||||||
|
Delay uint16 // 2 bytes
|
||||||
|
}
|
||||||
|
type KeyboardMacroReport struct {
|
||||||
|
IsPaste bool
|
||||||
|
Length uint32
|
||||||
|
Macro []KeyboardMacro
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardMacroReport returns the keyboard macro report from the message.
|
||||||
|
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
|
||||||
|
if m.t != TypeKeyboardMacroReport {
|
||||||
|
return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaste := m.d[0] == uint8(1)
|
||||||
|
length := binary.BigEndian.Uint32(m.d[1:5])
|
||||||
|
|
||||||
|
// check total length
|
||||||
|
expectedLength := int(length)*9 + 5
|
||||||
|
if len(m.d) != expectedLength {
|
||||||
|
return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro := make([]KeyboardMacro, 0, int(length))
|
||||||
|
for i := 0; i < int(length); i++ {
|
||||||
|
offset := 5 + i*9
|
||||||
|
|
||||||
|
macro = append(macro, KeyboardMacro{
|
||||||
|
Modifier: m.d[offset],
|
||||||
|
Keys: m.d[offset+1 : offset+7],
|
||||||
|
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyboardMacroReport{
|
||||||
|
IsPaste: isPaste,
|
||||||
|
Macro: macro,
|
||||||
|
Length: length,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PointerReport ..
|
// PointerReport ..
|
||||||
type PointerReport struct {
|
type PointerReport struct {
|
||||||
X int
|
X int
|
||||||
|
|
@ -131,3 +182,20 @@ func (m *Message) MouseReport() (MouseReport, error) {
|
||||||
Button: uint8(m.d[2]),
|
Button: uint8(m.d[2]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KeyboardMacroStateReport struct {
|
||||||
|
State bool
|
||||||
|
IsPaste bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardMacroStateReport returns the keyboard macro state report from the message.
|
||||||
|
func (m *Message) KeyboardMacroStateReport() (KeyboardMacroStateReport, error) {
|
||||||
|
if m.t != TypeKeyboardMacroStateReport {
|
||||||
|
return KeyboardMacroStateReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyboardMacroStateReport{
|
||||||
|
State: m.d[0] == uint8(1),
|
||||||
|
IsPaste: m.d[1] == uint8(1),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidSSHKeyTypes is a list of valid SSH key types
|
||||||
|
//
|
||||||
|
// Please make sure that all the types in this list are supported by dropbear
|
||||||
|
// https://github.com/mkj/dropbear/blob/003c5fcaabc114430d5d14142e95ffdbbd2d19b6/src/signkey.c#L37
|
||||||
|
//
|
||||||
|
// ssh-dss is not allowed here as it's insecure
|
||||||
|
var ValidSSHKeyTypes = []string{
|
||||||
|
ssh.KeyAlgoRSA,
|
||||||
|
ssh.KeyAlgoED25519,
|
||||||
|
ssh.KeyAlgoECDSA256,
|
||||||
|
ssh.KeyAlgoECDSA384,
|
||||||
|
ssh.KeyAlgoECDSA521,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSSHKey validates authorized_keys file content
|
||||||
|
func ValidateSSHKey(sshKey string) error {
|
||||||
|
// validate SSH key
|
||||||
|
var (
|
||||||
|
hasValidPublicKey = false
|
||||||
|
lastError = fmt.Errorf("no valid SSH key found")
|
||||||
|
)
|
||||||
|
for _, key := range strings.Split(sshKey, "\n") {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// skip empty lines and comments
|
||||||
|
if key == "" || strings.HasPrefix(key, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
|
||||||
|
if err != nil {
|
||||||
|
lastError = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedPublicKey == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedType := parsedPublicKey.Type()
|
||||||
|
textType := strings.Fields(key)[0]
|
||||||
|
|
||||||
|
if parsedType != textType {
|
||||||
|
lastError = fmt.Errorf("parsed SSH key type %s does not match type in text %s", parsedType, textType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(ValidSSHKeyTypes, parsedType) {
|
||||||
|
lastError = fmt.Errorf("invalid SSH key type: %s", parsedType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidPublicKey = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasValidPublicKey {
|
||||||
|
return lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateSSHKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sshKey string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid RSA key",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ED25519 key",
|
||||||
|
sshKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ECDSA key",
|
||||||
|
sshKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAlTkxIo4mXBR+gEX0Q74BpYX4bFFHoX+8Uz7tsob8HvsnMvsEE+BW9h9XrbWX4/4ppL/o6sHbvsqNr9HcyKfdc= test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple valid keys",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with comment",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with options and comment (we don't support options yet)",
|
||||||
|
sshKey: "command=\"echo hello\" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
sshKey: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
sshKey: " \n\t \n ",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comment only",
|
||||||
|
sshKey: "# This is a comment\n# Another comment",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key format",
|
||||||
|
sshKey: "not-a-valid-ssh-key",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key type",
|
||||||
|
sshKey: "ssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid SSH key type: ssh-dss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported key type",
|
||||||
|
sshKey: "ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid SSH key type: ssh-rsa-cert-v01@openssh.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed key data",
|
||||||
|
sshKey: "ssh-rsa invalid-base64-data",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type mismatch",
|
||||||
|
sshKey: "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIGomKoH...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "parsed SSH key type ssh-ed25519 does not match type in text ssh-rsa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed valid and invalid keys",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\ninvalid-key\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with empty lines and comments",
|
||||||
|
sshKey: "# Comment line\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\n# Another comment\n\t\n",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all invalid keys",
|
||||||
|
sshKey: "invalid-key-1\ninvalid-key-2\nssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateSSHKey(tt.sshKey)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ValidateSSHKey() expected error but got none")
|
||||||
|
} else if tt.errorMsg != "" && !strings.ContainsAny(err.Error(), tt.errorMsg) {
|
||||||
|
t.Errorf("ValidateSSHKey() error = %v, expected to contain %v", err, tt.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSSHKeyTypes(t *testing.T) {
|
||||||
|
expectedTypes := []string{
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ValidSSHKeyTypes) != len(expectedTypes) {
|
||||||
|
t.Errorf("ValidSSHKeyTypes length = %d, expected %d", len(ValidSSHKeyTypes), len(expectedTypes))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expectedType := range expectedTypes {
|
||||||
|
found := false
|
||||||
|
for _, actualType := range ValidSSHKeyTypes {
|
||||||
|
if actualType == expectedType {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("ValidSSHKeyTypes missing expected type: %s", expectedType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSSHKeyEdgeCases tests edge cases and boundary conditions
|
||||||
|
func TestValidateSSHKeyEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sshKey string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "key with only type",
|
||||||
|
sshKey: "ssh-rsa",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with type and empty data",
|
||||||
|
sshKey: "ssh-rsa ",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with type and whitespace data",
|
||||||
|
sshKey: "ssh-rsa \t ",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with multiple spaces between type and data",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with tabs",
|
||||||
|
sshKey: "\tssh-rsa\tAAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very long line",
|
||||||
|
sshKey: "ssh-rsa " + string(make([]byte, 10000)),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateSSHKey(tt.sshKey)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ValidateSSHKey() expected error but got none")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
133
jsonrpc.go
133
jsonrpc.go
|
|
@ -1,6 +1,7 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -10,13 +11,16 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
|
"github.com/jetkvm/kvm/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JSONRPCRequest struct {
|
type JSONRPCRequest struct {
|
||||||
|
|
@ -429,21 +433,27 @@ func rpcGetSSHKeyState() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetSSHKeyState(sshKey string) error {
|
func rpcSetSSHKeyState(sshKey string) error {
|
||||||
if sshKey != "" {
|
if sshKey == "" {
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
|
|
||||||
return fmt.Errorf("failed to create SSH key directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write SSH key to file
|
|
||||||
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
|
|
||||||
return fmt.Errorf("failed to write SSH key: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove SSH key file if empty string is provided
|
// Remove SSH key file if empty string is provided
|
||||||
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove SSH key file: %w", err)
|
return fmt.Errorf("failed to remove SSH key file: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate SSH key
|
||||||
|
if err := utils.ValidateSSHKey(sshKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create SSH key directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SSH key to file
|
||||||
|
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write SSH key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1049,6 +1059,107 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyboardMacroCancel context.CancelFunc
|
||||||
|
keyboardMacroLock sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// cancelKeyboardMacro cancels any ongoing keyboard macro execution
|
||||||
|
func cancelKeyboardMacro() {
|
||||||
|
keyboardMacroLock.Lock()
|
||||||
|
defer keyboardMacroLock.Unlock()
|
||||||
|
|
||||||
|
if keyboardMacroCancel != nil {
|
||||||
|
keyboardMacroCancel()
|
||||||
|
logger.Info().Msg("canceled keyboard macro")
|
||||||
|
keyboardMacroCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setKeyboardMacroCancel(cancel context.CancelFunc) {
|
||||||
|
keyboardMacroLock.Lock()
|
||||||
|
defer keyboardMacroLock.Unlock()
|
||||||
|
|
||||||
|
keyboardMacroCancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
setKeyboardMacroCancel(cancel)
|
||||||
|
|
||||||
|
s := hidrpc.KeyboardMacroStateReport{
|
||||||
|
State: true,
|
||||||
|
IsPaste: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSession != nil {
|
||||||
|
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
||||||
|
|
||||||
|
setKeyboardMacroCancel(nil)
|
||||||
|
|
||||||
|
s.State = false
|
||||||
|
if currentSession != nil {
|
||||||
|
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcCancelKeyboardMacro() {
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyboardClearStateKeys = make([]byte, 6)
|
||||||
|
|
||||||
|
func isClearKeyStep(step hidrpc.KeyboardMacro) bool {
|
||||||
|
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
|
||||||
|
var last usbgadget.KeysDownState
|
||||||
|
var err error
|
||||||
|
|
||||||
|
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro")
|
||||||
|
|
||||||
|
for i, step := range macro {
|
||||||
|
delay := time.Duration(step.Delay) * time.Millisecond
|
||||||
|
|
||||||
|
last, err = rpcKeyboardReport(step.Modifier, step.Keys)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to execute keyboard macro")
|
||||||
|
return last, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify the device that the keyboard state is being cleared
|
||||||
|
if isClearKeyStep(step) {
|
||||||
|
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use context-aware sleep that can be cancelled
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
// Sleep completed normally
|
||||||
|
case <-ctx.Done():
|
||||||
|
// make sure keyboard state is reset
|
||||||
|
_, err := rpcKeyboardReport(0, keyboardClearStateKeys)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to reset keyboard state")
|
||||||
|
}
|
||||||
|
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
|
||||||
|
|
||||||
|
logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep")
|
||||||
|
return last, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export default function InfoBar() {
|
||||||
|
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { rpcDataChannel } = useRTCStore();
|
||||||
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
||||||
|
const { isPasteModeEnabled } = useHidStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rpcDataChannel) return;
|
if (!rpcDataChannel) return;
|
||||||
|
|
@ -108,7 +109,12 @@ export default function InfoBar() {
|
||||||
<span className="text-xs">{rpcHidStatus}</span>
|
<span className="text-xs">{rpcHidStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isPasteModeEnabled && (
|
||||||
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
|
<span className="text-xs font-semibold">Paste Mode:</span>
|
||||||
|
<span className="text-xs">Enabled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showPressedKeys && (
|
{showPressedKeys && (
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">Keys:</span>
|
<span className="text-xs font-semibold">Keys:</span>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,39 @@ const USBStateMap: Record<USBStates, string> = {
|
||||||
"not attached": "Disconnected",
|
"not attached": "Disconnected",
|
||||||
suspended: "Low power mode",
|
suspended: "Low power mode",
|
||||||
};
|
};
|
||||||
|
const StatusCardProps: StatusProps = {
|
||||||
|
configured: {
|
||||||
|
icon: ({ className }) => (
|
||||||
|
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||||
|
),
|
||||||
|
iconClassName: "h-5 w-5 shrink-0",
|
||||||
|
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||||
|
},
|
||||||
|
attached: {
|
||||||
|
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||||
|
iconClassName: "h-5 w-5 text-blue-500",
|
||||||
|
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||||
|
},
|
||||||
|
addressed: {
|
||||||
|
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||||
|
iconClassName: "h-5 w-5 text-blue-500",
|
||||||
|
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||||
|
},
|
||||||
|
"not attached": {
|
||||||
|
icon: ({ className }) => (
|
||||||
|
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||||
|
),
|
||||||
|
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||||
|
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||||
|
},
|
||||||
|
suspended: {
|
||||||
|
icon: ({ className }) => (
|
||||||
|
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||||
|
),
|
||||||
|
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||||
|
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function USBStateStatus({
|
export default function USBStateStatus({
|
||||||
state,
|
state,
|
||||||
|
|
@ -30,39 +63,7 @@ export default function USBStateStatus({
|
||||||
state: USBStates;
|
state: USBStates;
|
||||||
peerConnectionState?: RTCPeerConnectionState | null;
|
peerConnectionState?: RTCPeerConnectionState | null;
|
||||||
}) {
|
}) {
|
||||||
const StatusCardProps: StatusProps = {
|
|
||||||
configured: {
|
|
||||||
icon: ({ className }) => (
|
|
||||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
|
||||||
),
|
|
||||||
iconClassName: "h-5 w-5 shrink-0",
|
|
||||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
|
||||||
},
|
|
||||||
attached: {
|
|
||||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
|
||||||
iconClassName: "h-5 w-5 text-blue-500",
|
|
||||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
|
||||||
},
|
|
||||||
addressed: {
|
|
||||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
|
||||||
iconClassName: "h-5 w-5 text-blue-500",
|
|
||||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
|
||||||
},
|
|
||||||
"not attached": {
|
|
||||||
icon: ({ className }) => (
|
|
||||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
|
||||||
),
|
|
||||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
|
||||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
|
||||||
},
|
|
||||||
suspended: {
|
|
||||||
icon: ({ className }) => (
|
|
||||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
|
||||||
),
|
|
||||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
|
||||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const props = StatusCardProps[state];
|
const props = StatusCardProps[state];
|
||||||
if (!props) {
|
if (!props) {
|
||||||
console.warn("Unsupported USB state: ", state);
|
console.warn("Unsupported USB state: ", state);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { LuCornerDownLeft } from "react-icons/lu";
|
import { LuCornerDownLeft } from "react-icons/lu";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useClose } from "@headlessui/react";
|
import { useClose } from "@headlessui/react";
|
||||||
|
|
@ -8,35 +8,32 @@ import { GridCard } from "@components/Card";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { KeyStroke } from "@/keyboardLayouts";
|
|
||||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
const hidKeyboardPayload = (modifier: number, keys: number[]) => {
|
|
||||||
return { modifier, keys };
|
|
||||||
};
|
|
||||||
|
|
||||||
const modifierCode = (shift?: boolean, altRight?: boolean) => {
|
|
||||||
return (shift ? modifiers.ShiftLeft : 0)
|
|
||||||
| (altRight ? modifiers.AltRight : 0)
|
|
||||||
}
|
|
||||||
const noModifier = 0
|
|
||||||
|
|
||||||
export default function PasteModal() {
|
export default function PasteModal() {
|
||||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const { setPasteModeEnabled } = useHidStore();
|
const { isPasteModeEnabled } = useHidStore();
|
||||||
const { setDisableVideoFocusTrap } = useUiStore();
|
const { setDisableVideoFocusTrap } = useUiStore();
|
||||||
|
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { executeMacro, cancelExecuteMacro } = useKeyboard();
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
|
const [delayValue, setDelayValue] = useState(100);
|
||||||
|
const delay = useMemo(() => {
|
||||||
|
if (delayValue < 50 || delayValue > 65534) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return delayValue;
|
||||||
|
}, [delayValue]);
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
|
|
||||||
const { setKeyboardLayout } = useSettingsStore();
|
const { setKeyboardLayout } = useSettingsStore();
|
||||||
const { selectedKeyboard } = useKeyboardLayout();
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
|
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
|
||||||
|
|
@ -46,21 +43,23 @@ export default function PasteModal() {
|
||||||
}, [send, setKeyboardLayout]);
|
}, [send, setKeyboardLayout]);
|
||||||
|
|
||||||
const onCancelPasteMode = useCallback(() => {
|
const onCancelPasteMode = useCallback(() => {
|
||||||
setPasteModeEnabled(false);
|
cancelExecuteMacro();
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
setInvalidChars([]);
|
setInvalidChars([]);
|
||||||
}, [setDisableVideoFocusTrap, setPasteModeEnabled]);
|
}, [setDisableVideoFocusTrap, cancelExecuteMacro]);
|
||||||
|
|
||||||
const onConfirmPaste = useCallback(async () => {
|
const onConfirmPaste = useCallback(async () => {
|
||||||
setPasteModeEnabled(false);
|
if (!TextAreaRef.current || !selectedKeyboard) return;
|
||||||
setDisableVideoFocusTrap(false);
|
|
||||||
|
|
||||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
|
||||||
if (!selectedKeyboard) return;
|
|
||||||
|
|
||||||
const text = TextAreaRef.current.value;
|
const text = TextAreaRef.current.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const macroSteps: {
|
||||||
|
keys: string[] | null;
|
||||||
|
modifiers: string[] | null;
|
||||||
|
delay: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
const keyprops = selectedKeyboard.chars[char];
|
const keyprops = selectedKeyboard.chars[char];
|
||||||
if (!keyprops) continue;
|
if (!keyprops) continue;
|
||||||
|
|
@ -70,39 +69,41 @@ export default function PasteModal() {
|
||||||
|
|
||||||
// if this is an accented character, we need to send that accent FIRST
|
// if this is an accented character, we need to send that accent FIRST
|
||||||
if (accentKey) {
|
if (accentKey) {
|
||||||
await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] })
|
const accentModifiers: string[] = [];
|
||||||
|
if (accentKey.shift) accentModifiers.push("ShiftLeft");
|
||||||
|
if (accentKey.altRight) accentModifiers.push("AltRight");
|
||||||
|
|
||||||
|
macroSteps.push({
|
||||||
|
keys: [String(accentKey.key)],
|
||||||
|
modifiers: accentModifiers.length > 0 ? accentModifiers : null,
|
||||||
|
delay,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// now send the actual key
|
// now send the actual key
|
||||||
await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]});
|
const modifiers: string[] = [];
|
||||||
|
if (shift) modifiers.push("ShiftLeft");
|
||||||
|
if (altRight) modifiers.push("AltRight");
|
||||||
|
|
||||||
|
macroSteps.push({
|
||||||
|
keys: [String(key)],
|
||||||
|
modifiers: modifiers.length > 0 ? modifiers : null,
|
||||||
|
delay
|
||||||
|
});
|
||||||
|
|
||||||
// if what was requested was a dead key, we need to send an unmodified space to emit
|
// if what was requested was a dead key, we need to send an unmodified space to emit
|
||||||
// just the accent character
|
// just the accent character
|
||||||
if (deadKey) {
|
if (deadKey) macroSteps.push({ keys: ["Space"], modifiers: null, delay });
|
||||||
await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// now send a message with no keys down to "release" the keys
|
if (macroSteps.length > 0) {
|
||||||
await sendKeystroke({ modifier: 0, keys: [] });
|
await executeMacro(macroSteps);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to paste text:", error);
|
console.error("Failed to paste text:", error);
|
||||||
notifications.error("Failed to paste text");
|
notifications.error("Failed to paste text");
|
||||||
}
|
}
|
||||||
|
}, [selectedKeyboard, executeMacro, delay]);
|
||||||
async function sendKeystroke(stroke: KeyStroke) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
send(
|
|
||||||
"keyboardReport",
|
|
||||||
hidKeyboardPayload(stroke.modifier, stroke.keys),
|
|
||||||
params => {
|
|
||||||
if ("error" in params) return reject(params.error);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (TextAreaRef.current) {
|
if (TextAreaRef.current) {
|
||||||
|
|
@ -122,14 +123,18 @@ export default function PasteModal() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="animate-fadeIn opacity-0 space-y-2"
|
className="animate-fadeIn space-y-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.1s",
|
animationDelay: "0.1s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
<div
|
||||||
|
className="w-full"
|
||||||
|
onKeyUp={e => e.stopPropagation()}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
ref={TextAreaRef}
|
ref={TextAreaRef}
|
||||||
label="Paste from host"
|
label="Paste from host"
|
||||||
|
|
@ -171,9 +176,31 @@ export default function PasteModal() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
<InputFieldWithLabel
|
||||||
|
type="number"
|
||||||
|
label="Delay between keys"
|
||||||
|
placeholder="Delay between keys"
|
||||||
|
min={50}
|
||||||
|
max={65534}
|
||||||
|
value={delayValue}
|
||||||
|
onChange={e => {
|
||||||
|
setDelayValue(parseInt(e.target.value, 10));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{delayValue < 50 || delayValue > 65534 && (
|
||||||
|
<div className="mt-2 flex items-center gap-x-2">
|
||||||
|
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||||
|
<span className="text-xs text-red-500 dark:text-red-400">
|
||||||
|
Delay must be between 50 and 65534
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
|
Sending text using keyboard layout: {selectedKeyboard.isoCode}-
|
||||||
|
{selectedKeyboard.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,7 +208,7 @@ export default function PasteModal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex animate-fadeIn opacity-0 items-center justify-end gap-x-2"
|
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
|
|
@ -200,6 +227,7 @@ export default function PasteModal() {
|
||||||
size="SM"
|
size="SM"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
text="Confirm Paste"
|
text="Confirm Paste"
|
||||||
|
disabled={isPasteModeEnabled}
|
||||||
onClick={onConfirmPaste}
|
onClick={onConfirmPaste}
|
||||||
LeadingIcon={LuCornerDownLeft}
|
LeadingIcon={LuCornerDownLeft}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ export const HID_RPC_MESSAGE_TYPES = {
|
||||||
WheelReport: 0x04,
|
WheelReport: 0x04,
|
||||||
KeypressReport: 0x05,
|
KeypressReport: 0x05,
|
||||||
MouseReport: 0x06,
|
MouseReport: 0x06,
|
||||||
|
KeyboardMacroReport: 0x07,
|
||||||
|
CancelKeyboardMacroReport: 0x08,
|
||||||
KeyboardLedState: 0x32,
|
KeyboardLedState: 0x32,
|
||||||
KeysDownState: 0x33,
|
KeysDownState: 0x33,
|
||||||
|
KeyboardMacroStateReport: 0x34,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
|
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
|
||||||
|
|
@ -32,6 +35,30 @@ const fromInt32toUint8 = (n: number) => {
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fromUint16toUint8 = (n: number) => {
|
||||||
|
if (n > 65535 || n < 0) {
|
||||||
|
throw new Error(`Number ${n} is not within the uint16 range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array([
|
||||||
|
(n >> 8) & 0xFF,
|
||||||
|
(n >> 0) & 0xFF,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromUint32toUint8 = (n: number) => {
|
||||||
|
if (n > 4294967295 || n < 0) {
|
||||||
|
throw new Error(`Number ${n} is not within the uint32 range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array([
|
||||||
|
(n >> 24) & 0xFF,
|
||||||
|
(n >> 16) & 0xFF,
|
||||||
|
(n >> 8) & 0xFF,
|
||||||
|
(n >> 0) & 0xFF,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const fromInt8ToUint8 = (n: number) => {
|
const fromInt8ToUint8 = (n: number) => {
|
||||||
if (n < -128 || n > 127) {
|
if (n < -128 || n > 127) {
|
||||||
throw new Error(`Number ${n} is not within the int8 range`);
|
throw new Error(`Number ${n} is not within the int8 range`);
|
||||||
|
|
@ -186,6 +213,99 @@ export class KeyboardReportMessage extends RpcMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KeyboardMacroStep extends KeysDownState {
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyboardMacroReportMessage extends RpcMessage {
|
||||||
|
isPaste: boolean;
|
||||||
|
length: number;
|
||||||
|
steps: KeyboardMacroStep[];
|
||||||
|
|
||||||
|
KEYS_LENGTH = 6;
|
||||||
|
|
||||||
|
constructor(isPaste: boolean, length: number, steps: KeyboardMacroStep[]) {
|
||||||
|
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport);
|
||||||
|
this.isPaste = isPaste;
|
||||||
|
this.length = length;
|
||||||
|
this.steps = steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
marshal(): Uint8Array {
|
||||||
|
// validate if length is correct
|
||||||
|
if (this.length !== this.steps.length) {
|
||||||
|
throw new Error(`Length ${this.length} is not equal to the number of steps ${this.steps.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(this.length * 9 + 6);
|
||||||
|
data.set(new Uint8Array([
|
||||||
|
this.messageType,
|
||||||
|
this.isPaste ? 1 : 0,
|
||||||
|
...fromUint32toUint8(this.length),
|
||||||
|
]), 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < this.length; i++) {
|
||||||
|
const step = this.steps[i];
|
||||||
|
if (!withinUint8Range(step.modifier)) {
|
||||||
|
throw new Error(`Modifier ${step.modifier} is not within the uint8 range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the keys are within the KEYS_LENGTH range
|
||||||
|
const keys = step.keys;
|
||||||
|
if (keys.length > this.KEYS_LENGTH) {
|
||||||
|
throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`);
|
||||||
|
} else if (keys.length < this.KEYS_LENGTH) {
|
||||||
|
keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!withinUint8Range(key)) {
|
||||||
|
throw new Error(`Key ${key} is not within the uint8 range`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const macroBinary = new Uint8Array([
|
||||||
|
step.modifier,
|
||||||
|
...keys,
|
||||||
|
...fromUint16toUint8(step.delay),
|
||||||
|
]);
|
||||||
|
const offset = 6 + i * 9;
|
||||||
|
|
||||||
|
|
||||||
|
data.set(macroBinary, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyboardMacroStateReportMessage extends RpcMessage {
|
||||||
|
state: boolean;
|
||||||
|
isPaste: boolean;
|
||||||
|
|
||||||
|
constructor(state: boolean, isPaste: boolean) {
|
||||||
|
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport);
|
||||||
|
this.state = state;
|
||||||
|
this.isPaste = isPaste;
|
||||||
|
}
|
||||||
|
|
||||||
|
marshal(): Uint8Array {
|
||||||
|
return new Uint8Array([
|
||||||
|
this.messageType,
|
||||||
|
this.state ? 1 : 0,
|
||||||
|
this.isPaste ? 1 : 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unmarshal(data: Uint8Array): KeyboardMacroStateReportMessage | undefined {
|
||||||
|
if (data.length < 1) {
|
||||||
|
throw new Error(`Invalid keyboard macro state report message length: ${data.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new KeyboardMacroStateReportMessage(data[0] === 1, data[1] === 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class KeyboardLedStateMessage extends RpcMessage {
|
export class KeyboardLedStateMessage extends RpcMessage {
|
||||||
keyboardLedState: KeyboardLedState;
|
keyboardLedState: KeyboardLedState;
|
||||||
|
|
||||||
|
|
@ -256,6 +376,17 @@ export class PointerReportMessage extends RpcMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CancelKeyboardMacroReportMessage extends RpcMessage {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
marshal(): Uint8Array {
|
||||||
|
return new Uint8Array([this.messageType]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MouseReportMessage extends RpcMessage {
|
export class MouseReportMessage extends RpcMessage {
|
||||||
dx: number;
|
dx: number;
|
||||||
dy: number;
|
dy: number;
|
||||||
|
|
@ -284,6 +415,9 @@ export const messageRegistry = {
|
||||||
[HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage,
|
[HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage,
|
||||||
[HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage,
|
[HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage,
|
||||||
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
|
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
|
||||||
|
[HID_RPC_MESSAGE_TYPES.KeyboardMacroReport]: KeyboardMacroReportMessage,
|
||||||
|
[HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage,
|
||||||
|
[HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport]: KeyboardMacroStateReportMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {
|
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CancelKeyboardMacroReportMessage,
|
||||||
HID_RPC_VERSION,
|
HID_RPC_VERSION,
|
||||||
HandshakeMessage,
|
HandshakeMessage,
|
||||||
|
KeyboardMacroStep,
|
||||||
|
KeyboardMacroReportMessage,
|
||||||
KeyboardReportMessage,
|
KeyboardReportMessage,
|
||||||
KeypressReportMessage,
|
KeypressReportMessage,
|
||||||
MouseReportMessage,
|
MouseReportMessage,
|
||||||
|
|
@ -68,6 +71,21 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
|
||||||
[sendMessage],
|
[sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reportKeyboardMacroEvent = useCallback(
|
||||||
|
(macro: KeyboardMacroStep[]) => {
|
||||||
|
const d = new KeyboardMacroReportMessage(false, macro.length, macro);
|
||||||
|
sendMessage(d);
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelOngoingKeyboardMacro = useCallback(
|
||||||
|
() => {
|
||||||
|
sendMessage(new CancelKeyboardMacroReportMessage());
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const sendHandshake = useCallback(() => {
|
const sendHandshake = useCallback(() => {
|
||||||
if (rpcHidProtocolVersion) return;
|
if (rpcHidProtocolVersion) return;
|
||||||
if (!rpcHidChannel) return;
|
if (!rpcHidChannel) return;
|
||||||
|
|
@ -143,6 +161,8 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
|
||||||
reportKeypressEvent,
|
reportKeypressEvent,
|
||||||
reportAbsMouseEvent,
|
reportAbsMouseEvent,
|
||||||
reportRelMouseEvent,
|
reportRelMouseEvent,
|
||||||
|
reportKeyboardMacroEvent,
|
||||||
|
cancelOngoingKeyboardMacro,
|
||||||
rpcHidProtocolVersion,
|
rpcHidProtocolVersion,
|
||||||
rpcHidReady,
|
rpcHidReady,
|
||||||
rpcHidStatus,
|
rpcHidStatus,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores";
|
import {
|
||||||
|
hidErrorRollOver,
|
||||||
|
hidKeyBufferSize,
|
||||||
|
KeysDownState,
|
||||||
|
useHidStore,
|
||||||
|
useRTCStore,
|
||||||
|
} from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidRpc } from "@/hooks/useHidRpc";
|
import { useHidRpc } from "@/hooks/useHidRpc";
|
||||||
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc";
|
import { KeyboardLedStateMessage, KeyboardMacroStateReportMessage, KeyboardMacroStep, KeysDownStateMessage } from "@/hooks/hidRpc";
|
||||||
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
||||||
|
|
||||||
export default function useKeyboard() {
|
export default function useKeyboard() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { rpcDataChannel } = useRTCStore();
|
||||||
const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore();
|
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = useHidStore();
|
||||||
|
|
||||||
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
|
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
|
||||||
// being tracked on the browser/client-side. When adding the keyPressReport API to the
|
// being tracked on the browser/client-side. When adding the keyPressReport API to the
|
||||||
|
|
@ -17,17 +23,19 @@ export default function useKeyboard() {
|
||||||
// is running on the cloud against a device that has not been updated yet and thus does not
|
// is running on the cloud against a device that has not been updated yet and thus does not
|
||||||
// support the keyPressReport API. In that case, we need to handle the key presses locally
|
// support the keyPressReport API. In that case, we need to handle the key presses locally
|
||||||
// and send the full state to the device, so it can behave like a real USB HID keyboard.
|
// and send the full state to the device, so it can behave like a real USB HID keyboard.
|
||||||
// This flag indicates whether the keyPressReport API is available on the device which is
|
// This flag indicates whether the keyPressReport API is available on the device which is
|
||||||
// dynamically set when the device responds to the first key press event or reports its
|
// dynamically set when the device responds to the first key press event or reports its
|
||||||
// keysDownState when queried since the keyPressReport was introduced together with the
|
// keysDownState when queried since the keyPressReport was introduced together with the
|
||||||
// getKeysDownState API.
|
// getKeysDownState API.
|
||||||
|
|
||||||
// HidRPC is a binary format for exchanging keyboard and mouse events
|
// HidRPC is a binary format for exchanging keyboard and mouse events
|
||||||
const {
|
const {
|
||||||
reportKeyboardEvent: sendKeyboardEventHidRpc,
|
reportKeyboardEvent: sendKeyboardEventHidRpc,
|
||||||
reportKeypressEvent: sendKeypressEventHidRpc,
|
reportKeypressEvent: sendKeypressEventHidRpc,
|
||||||
|
reportKeyboardMacroEvent: sendKeyboardMacroEventHidRpc,
|
||||||
|
cancelOngoingKeyboardMacro: cancelOngoingKeyboardMacroHidRpc,
|
||||||
rpcHidReady,
|
rpcHidReady,
|
||||||
} = useHidRpc((message) => {
|
} = useHidRpc(message => {
|
||||||
switch (message.constructor) {
|
switch (message.constructor) {
|
||||||
case KeysDownStateMessage:
|
case KeysDownStateMessage:
|
||||||
setKeysDownState((message as KeysDownStateMessage).keysDownState);
|
setKeysDownState((message as KeysDownStateMessage).keysDownState);
|
||||||
|
|
@ -35,6 +43,10 @@ export default function useKeyboard() {
|
||||||
case KeyboardLedStateMessage:
|
case KeyboardLedStateMessage:
|
||||||
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
|
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
|
||||||
break;
|
break;
|
||||||
|
case KeyboardMacroStateReportMessage:
|
||||||
|
if (!(message as KeyboardMacroStateReportMessage).isPaste) break;
|
||||||
|
setPasteModeEnabled((message as KeyboardMacroStateReportMessage).state);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +60,9 @@ export default function useKeyboard() {
|
||||||
async (state: KeysDownState) => {
|
async (state: KeysDownState) => {
|
||||||
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
|
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
|
||||||
|
|
||||||
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
|
console.debug(
|
||||||
|
`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (rpcHidReady) {
|
if (rpcHidReady) {
|
||||||
console.debug("Sending keyboard report via HidRPC");
|
console.debug("Sending keyboard report via HidRPC");
|
||||||
|
|
@ -56,31 +70,32 @@ export default function useKeyboard() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
|
send(
|
||||||
if ("error" in resp) {
|
"keyboardReport",
|
||||||
console.error(`Failed to send keyboard report ${state}`, resp.error);
|
{ keys: state.keys, modifier: state.modifier },
|
||||||
}
|
(resp: JsonRpcResponse) => {
|
||||||
});
|
if ("error" in resp) {
|
||||||
|
console.error(`Failed to send keyboard report ${state}`, resp.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[
|
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
|
||||||
rpcDataChannel?.readyState,
|
|
||||||
rpcHidReady,
|
|
||||||
send,
|
|
||||||
sendKeyboardEventHidRpc,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MACRO_RESET_KEYBOARD_STATE = useMemo(() => ({
|
||||||
|
keys: new Array(hidKeyBufferSize).fill(0),
|
||||||
|
modifier: 0,
|
||||||
|
delay: 0,
|
||||||
|
}), []);
|
||||||
|
|
||||||
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
|
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
|
||||||
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
|
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
|
||||||
// is clean.
|
// is clean.
|
||||||
const resetKeyboardState = useCallback(
|
const resetKeyboardState = useCallback(async () => {
|
||||||
async () => {
|
// Reset the keys buffer to zeros and the modifier state to zero
|
||||||
// Reset the keys buffer to zeros and the modifier state to zero
|
sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE);
|
||||||
keysDownState.keys.length = hidKeyBufferSize;
|
}, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]);
|
||||||
keysDownState.keys.fill(0);
|
|
||||||
keysDownState.modifier = 0;
|
|
||||||
sendKeyboardEvent(keysDownState);
|
|
||||||
}, [keysDownState, sendKeyboardEvent]);
|
|
||||||
|
|
||||||
// executeMacro is used to execute a macro consisting of multiple steps.
|
// executeMacro is used to execute a macro consisting of multiple steps.
|
||||||
// Each step can have multiple keys, multiple modifiers and a delay.
|
// Each step can have multiple keys, multiple modifiers and a delay.
|
||||||
|
|
@ -88,29 +103,32 @@ export default function useKeyboard() {
|
||||||
// After the delay, the keys and modifiers are released and the next step is executed.
|
// After the delay, the keys and modifiers are released and the next step is executed.
|
||||||
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
||||||
// A small pause is added between steps to ensure that the device can process the events.
|
// A small pause is added between steps to ensure that the device can process the events.
|
||||||
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
const executeMacro = async (
|
||||||
for (const [index, step] of steps.entries()) {
|
steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
|
||||||
|
) => {
|
||||||
|
const macro: KeyboardMacroStep[] = [];
|
||||||
|
|
||||||
|
for (const [_, step] of steps.entries()) {
|
||||||
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
|
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
|
||||||
const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0);
|
const modifierMask: number = (step.modifiers || [])
|
||||||
|
.map(mod => modifiers[mod])
|
||||||
|
.reduce((acc, val) => acc + val, 0);
|
||||||
|
|
||||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
// If the step has keys and/or modifiers, press them and hold for the delay
|
||||||
if (keyValues.length > 0 || modifierMask > 0) {
|
if (keyValues.length > 0 || modifierMask > 0) {
|
||||||
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask });
|
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
|
||||||
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
|
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||||
|
|
||||||
resetKeyboardState();
|
|
||||||
} else {
|
|
||||||
// This is a delay-only step, just wait for the delay amount
|
|
||||||
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a small pause between steps if not the last step
|
|
||||||
if (index < steps.length - 1) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendKeyboardMacroEventHidRpc(macro);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelExecuteMacro = useCallback(async () => {
|
||||||
|
if (!rpcHidReady) return;
|
||||||
|
cancelOngoingKeyboardMacroHidRpc();
|
||||||
|
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc]);
|
||||||
|
|
||||||
// handleKeyPress is used to handle a key press or release event.
|
// handleKeyPress is used to handle a key press or release event.
|
||||||
// This function handle both key press and key release events.
|
// This function handle both key press and key release events.
|
||||||
// It checks if the keyPressReport API is available and sends the key press event.
|
// It checks if the keyPressReport API is available and sends the key press event.
|
||||||
|
|
@ -132,7 +150,11 @@ export default function useKeyboard() {
|
||||||
sendKeypressEventHidRpc(key, press);
|
sendKeypressEventHidRpc(key, press);
|
||||||
} else {
|
} else {
|
||||||
// if the keyPress api is not available, we need to handle the key locally
|
// if the keyPress api is not available, we need to handle the key locally
|
||||||
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
|
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(
|
||||||
|
keysDownState,
|
||||||
|
key,
|
||||||
|
press,
|
||||||
|
);
|
||||||
sendKeyboardEvent(downState); // then we send the full state
|
sendKeyboardEvent(downState); // then we send the full state
|
||||||
|
|
||||||
// if we just sent ErrorRollOver, reset to empty state
|
// if we just sent ErrorRollOver, reset to empty state
|
||||||
|
|
@ -152,7 +174,11 @@ export default function useKeyboard() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
|
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
|
||||||
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
|
function simulateDeviceSideKeyHandlingForLegacyDevices(
|
||||||
|
state: KeysDownState,
|
||||||
|
key: number,
|
||||||
|
press: boolean,
|
||||||
|
): KeysDownState {
|
||||||
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
|
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
|
||||||
// for handling key presses and releases. It ensures that the USB gadget
|
// for handling key presses and releases. It ensures that the USB gadget
|
||||||
// behaves similarly to a real USB HID keyboard. This logic is paralleled
|
// behaves similarly to a real USB HID keyboard. This logic is paralleled
|
||||||
|
|
@ -164,7 +190,7 @@ export default function useKeyboard() {
|
||||||
if (modifierMask !== 0) {
|
if (modifierMask !== 0) {
|
||||||
// If the key is a modifier key, we update the keyboardModifier state
|
// If the key is a modifier key, we update the keyboardModifier state
|
||||||
// by setting or clearing the corresponding bit in the modifier byte.
|
// by setting or clearing the corresponding bit in the modifier byte.
|
||||||
// This allows us to track the state of dynamic modifier keys like
|
// This allows us to track the state of dynamic modifier keys like
|
||||||
// Shift, Control, Alt, and Super.
|
// Shift, Control, Alt, and Super.
|
||||||
if (press) {
|
if (press) {
|
||||||
modifiers |= modifierMask;
|
modifiers |= modifierMask;
|
||||||
|
|
@ -181,7 +207,7 @@ export default function useKeyboard() {
|
||||||
// and if we find a zero byte, we can place the key there (if press is true)
|
// and if we find a zero byte, we can place the key there (if press is true)
|
||||||
if (keys[i] === key || keys[i] === 0) {
|
if (keys[i] === key || keys[i] === 0) {
|
||||||
if (press) {
|
if (press) {
|
||||||
keys[i] = key // overwrites the zero byte or the same key if already pressed
|
keys[i] = key; // overwrites the zero byte or the same key if already pressed
|
||||||
} else {
|
} else {
|
||||||
// we are releasing the key, remove it from the buffer
|
// we are releasing the key, remove it from the buffer
|
||||||
if (keys[i] !== 0) {
|
if (keys[i] !== 0) {
|
||||||
|
|
@ -197,18 +223,20 @@ export default function useKeyboard() {
|
||||||
// If we reach here it means we didn't find an empty slot or the key in the buffer
|
// If we reach here it means we didn't find an empty slot or the key in the buffer
|
||||||
if (overrun) {
|
if (overrun) {
|
||||||
if (press) {
|
if (press) {
|
||||||
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
|
console.warn(
|
||||||
|
`keyboard buffer overflow current keys ${keys}, key: ${key} not added`,
|
||||||
|
);
|
||||||
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
|
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
|
||||||
keys.length = hidKeyBufferSize;
|
keys.length = hidKeyBufferSize;
|
||||||
keys.fill(hidErrorRollOver);
|
keys.fill(hidErrorRollOver);
|
||||||
} else {
|
} else {
|
||||||
// If we are releasing a key, and we didn't find it in a slot, who cares?
|
// If we are releasing a key, and we didn't find it in a slot, who cares?
|
||||||
console.debug(`key ${key} not found in buffer, nothing to release`)
|
console.debug(`key ${key} not found in buffer, nothing to release`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { modifier: modifiers, keys };
|
return { modifier: modifiers, keys };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleKeyPress, resetKeyboardState, executeMacro };
|
return { handleKeyPress, resetKeyboardState, executeMacro, cancelExecuteMacro };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,20 +31,35 @@ export default defineConfig(({ mode, command }) => {
|
||||||
esbuild: {
|
esbuild: {
|
||||||
pure: ["console.debug"],
|
pure: ["console.debug"],
|
||||||
},
|
},
|
||||||
build: { outDir: isCloud ? "dist" : "../static" },
|
build: {
|
||||||
|
outDir: isCloud ? "dist" : "../static",
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: (id) => {
|
||||||
|
if (id.includes("node_modules")) {
|
||||||
|
return "vendor";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
assetFileNames: "assets/immutable/[name]-[hash][extname]",
|
||||||
|
chunkFileNames: "assets/immutable/[name]-[hash].js",
|
||||||
|
entryFileNames: "assets/immutable/[name]-[hash].js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
https: useSSL,
|
https: useSSL,
|
||||||
proxy: JETKVM_PROXY_URL
|
proxy: JETKVM_PROXY_URL
|
||||||
? {
|
? {
|
||||||
"/me": JETKVM_PROXY_URL,
|
"/me": JETKVM_PROXY_URL,
|
||||||
"/device": JETKVM_PROXY_URL,
|
"/device": JETKVM_PROXY_URL,
|
||||||
"/webrtc": JETKVM_PROXY_URL,
|
"/webrtc": JETKVM_PROXY_URL,
|
||||||
"/auth": JETKVM_PROXY_URL,
|
"/auth": JETKVM_PROXY_URL,
|
||||||
"/storage": JETKVM_PROXY_URL,
|
"/storage": JETKVM_PROXY_URL,
|
||||||
"/cloud": JETKVM_PROXY_URL,
|
"/cloud": JETKVM_PROXY_URL,
|
||||||
"/developer": JETKVM_PROXY_URL,
|
"/developer": JETKVM_PROXY_URL,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
base: onDevice && command === "build" ? "/static" : "/",
|
base: onDevice && command === "build" ? "/static" : "/",
|
||||||
|
|
|
||||||
28
web.go
28
web.go
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ import (
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/vearutop/statigz"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,6 +68,11 @@ type SetupRequest struct {
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cachableFileExtensions = []string{
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".woff2",
|
||||||
|
".ico",
|
||||||
|
}
|
||||||
|
|
||||||
func setupRouter() *gin.Engine {
|
func setupRouter() *gin.Engine {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
gin.DisableConsoleColor()
|
gin.DisableConsoleColor()
|
||||||
|
|
@ -75,23 +82,36 @@ func setupRouter() *gin.Engine {
|
||||||
return *ginLogger
|
return *ginLogger
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
|
|
||||||
staticFS, _ := fs.Sub(staticFiles, "static")
|
staticFS, _ := fs.Sub(staticFiles, "static")
|
||||||
|
staticFileServer := http.StripPrefix("/static", statigz.FileServer(
|
||||||
|
staticFS.(fs.ReadDirFS),
|
||||||
|
))
|
||||||
|
|
||||||
// Add a custom middleware to set cache headers for images
|
// Add a custom middleware to set cache headers for images
|
||||||
// This is crucial for optimizing the initial welcome screen load time
|
// This is crucial for optimizing the initial welcome screen load time
|
||||||
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache
|
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache
|
||||||
// This allows for a smoother enter animation and improved user experience on the welcome screen
|
// This allows for a smoother enter animation and improved user experience on the welcome screen
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/static/assets/immutable/") {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
|
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
|
||||||
ext := filepath.Ext(c.Request.URL.Path)
|
ext := filepath.Ext(c.Request.URL.Path)
|
||||||
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
|
if slices.Contains(cachableFileExtensions, ext) {
|
||||||
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
|
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
r.StaticFS("/static", http.FS(staticFS))
|
r.Any("/static/*w", func(c *gin.Context) {
|
||||||
|
staticFileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
r.POST("/auth/login-local", handleLogin)
|
r.POST("/auth/login-local", handleLogin)
|
||||||
|
|
||||||
// We use this to determine if the device is setup
|
// We use this to determine if the device is setup
|
||||||
|
|
@ -198,6 +218,10 @@ func handleWebRTCSession(c *gin.Context) {
|
||||||
_ = peerConn.Close()
|
_ = peerConn.Close()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any ongoing keyboard report multi when session changes
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
currentSession = session
|
currentSession = session
|
||||||
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,8 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
if connectionState == webrtc.ICEConnectionStateClosed {
|
if connectionState == webrtc.ICEConnectionStateClosed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
||||||
if session == currentSession {
|
if session == currentSession {
|
||||||
|
// Cancel any ongoing keyboard report multi when session closes
|
||||||
|
cancelKeyboardMacro()
|
||||||
currentSession = nil
|
currentSession = nil
|
||||||
}
|
}
|
||||||
// Stop RPC processor
|
// Stop RPC processor
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue