Merge branch 'dev' into feat/audio-support

This commit is contained in:
Alex P 2025-09-05 14:40:23 +00:00
commit 9cb976ab8d
125 changed files with 4429 additions and 2904 deletions

View File

@ -15,7 +15,7 @@ jobs:
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v4
with:

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v4.2.2
- name: Install Go
uses: actions/setup-go@fa96338abe5531f6e34c5cc0bbe28c1a533d5505 # v4.2.1
with:

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v4
with:

View File

@ -22,25 +22,12 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
return 0, errors.New("image not mounted")
}
source := currentVirtualMediaState.Source
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
readLen := int64(len(p))
if off+readLen > mountedImageSize {
readLen = mountedImageSize - off
}
var data []byte
switch source {
case WebRTC:
data, err = webRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
case HTTP:
return httpRangeReader.ReadAt(p, off)
default:

View File

@ -114,7 +114,7 @@ var defaultConfig = &Config{
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en_US",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes

View File

@ -30,7 +30,7 @@ const (
// do not call this function directly, use switchToScreenIfDifferent instead
// this function is not thread safe
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
_, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen})
if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return
@ -39,15 +39,15 @@ func switchToScreen(screen string) {
}
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state})
}
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag})
}
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag})
}
func lvObjHide(objName string) (*CtrlResponse, error) {
@ -59,27 +59,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) {
}
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity})
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity})
}
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration})
}
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration})
}
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text})
}
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src})
}
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation})
}
func updateLabelIfChanged(objName string, newText string) {

114
fuse.go
View File

@ -1,114 +0,0 @@
package kvm
import (
"context"
"os"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WebRTCStreamFile struct {
fs.Inode
mu sync.Mutex
Attr fuse.Attr
size uint64
}
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WebRTCStreamFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
return 0, syscall.EROFS
}
var _ = (fs.NodeGetattrer)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
out.Size = f.size
return fs.OK
}
func (f *WebRTCStreamFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
return fs.OK
}
func (f *WebRTCStreamFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
type DiskReadRequest struct {
Start uint64 `json:"start"`
End uint64 `json:"end"`
}
var diskReadChan = make(chan []byte, 1)
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
if err != nil {
return nil, syscall.EIO
}
return fuse.ReadResultData(buf), fs.OK
}
func (f *WebRTCStreamFile) SetSize(size uint64) {
f.mu.Lock()
defer f.mu.Unlock()
f.size = size
}
type FuseRoot struct {
fs.Inode
}
var webRTCStreamFile = &WebRTCStreamFile{}
func (r *FuseRoot) OnAdd(ctx context.Context) {
ch := r.NewPersistentInode(ctx, webRTCStreamFile, fs.StableAttr{Ino: 2})
r.AddChild("disk", ch, false)
}
func (r *FuseRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mode = 0755
return 0
}
var _ = (fs.NodeGetattrer)((*FuseRoot)(nil))
var _ = (fs.NodeOnAdder)((*FuseRoot)(nil))
const fuseMountPoint = "/mnt/webrtc"
var fuseServer *fuse.Server
func RunFuseServer() {
opts := &fs.Options{}
opts.DirectMountStrict = true
_ = os.Mkdir(fuseMountPoint, 0755)
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
logger.Warn().Err(err).Msg("failed to mount fuse")
}
fuseServer.Wait()
}
type WebRTCImage struct {
Size uint64 `json:"size"`
Filename string `json:"filename"`
}

39
go.mod
View File

@ -6,32 +6,31 @@ require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.14.1
github.com/coreos/go-oidc/v3 v3.15.0
github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.3
github.com/go-co-op/gocron/v2 v2.16.5
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
github.com/hanwen/go-fuse/v2 v2.8.0
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.1.3
github.com/pion/webrtc/v4 v4.1.4
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/common v0.65.0
github.com/prometheus/procfs v0.16.1
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/common v0.66.0
github.com/prometheus/procfs v0.17.0
github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.40.0
golang.org/x/net v0.41.0
golang.org/x/sys v0.34.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/sys v0.35.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -51,6 +50,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
@ -63,18 +63,18 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/dtls/v3 v3.0.7 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.20 // indirect
github.com/pion/rtp v1.8.22 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.14 // indirect
github.com/pion/srtp/v3 v3.0.6 // indirect
github.com/pion/sdp/v3 v3.0.16 // indirect
github.com/pion/srtp/v3 v3.0.7 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pion/turn/v4 v4.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
@ -85,7 +85,8 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

80
go.sum
View File

@ -18,8 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
@ -38,8 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -58,12 +58,12 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoNJH4uUmsQ86+0/QqpwEns0NyNLwKv0=
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -92,8 +92,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -107,8 +105,8 @@ github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
@ -121,33 +119,33 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc=
github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs=
github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M=
github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@ -167,8 +165,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
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=
@ -185,10 +183,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -196,15 +194,17 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

162
hidrpc.go Normal file
View File

@ -0,0 +1,162 @@
package kvm
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget"
)
func handleHidRPCMessage(message hidrpc.Message, session *Session) {
var rpcErr error
switch message.Type() {
case hidrpc.TypeHandshake:
message, err := hidrpc.NewHandshakeMessage().Marshal()
if err != nil {
logger.Warn().Err(err).Msg("failed to marshal handshake message")
return
}
if err := session.HidChannel.Send(message); err != nil {
logger.Warn().Err(err).Msg("failed to send handshake message")
return
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
keysDownState, err := handleHidRPCKeyboardInput(message)
if keysDownState != nil {
session.reportHidRPCKeysDownState(*keysDownState)
}
rpcErr = err
case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get pointer report")
return
}
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
case hidrpc.TypeMouseReport:
mouseReport, err := message.MouseReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get mouse report")
return
}
rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button)
default:
logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type")
}
if rpcErr != nil {
logger.Warn().Err(rpcErr).Msg("failed to handle HID RPC message")
}
}
func onHidMessage(data []byte, session *Session) {
scopedLogger := hidRPCLogger.With().Bytes("data", data).Logger()
scopedLogger.Debug().Msg("HID RPC message received")
if len(data) < 1 {
scopedLogger.Warn().Int("length", len(data)).Msg("received empty data in HID RPC message handler")
return
}
var message hidrpc.Message
if err := hidrpc.Unmarshal(data, &message); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to unmarshal HID RPC message")
return
}
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
t := time.Now()
r := make(chan interface{})
go func() {
handleHidRPCMessage(message, session)
r <- nil
}()
select {
case <-time.After(1 * time.Second):
scopedLogger.Warn().Msg("HID RPC message timed out")
case <-r:
scopedLogger.Debug().Dur("duration", time.Since(t)).Msg("HID RPC message handled")
}
}
func handleHidRPCKeyboardInput(message hidrpc.Message) (*usbgadget.KeysDownState, error) {
switch message.Type() {
case hidrpc.TypeKeypressReport:
keypressReport, err := message.KeypressReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keypress report")
return nil, err
}
keysDownState, rpcError := rpcKeypressReport(keypressReport.Key, keypressReport.Press)
return &keysDownState, rpcError
case hidrpc.TypeKeyboardReport:
keyboardReport, err := message.KeyboardReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard report")
return nil, err
}
keysDownState, rpcError := rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
return &keysDownState, rpcError
}
return nil, fmt.Errorf("unknown HID RPC message type: %d", message.Type())
}
func reportHidRPC(params any, session *Session) {
if session == nil {
logger.Warn().Msg("session is nil, skipping reportHidRPC")
return
}
if !session.hidRPCAvailable || session.HidChannel == nil {
logger.Warn().Msg("HID RPC is not available, skipping reportHidRPC")
return
}
var (
message []byte
err error
)
switch params := params.(type) {
case usbgadget.KeyboardState:
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
case usbgadget.KeysDownState:
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
default:
err = fmt.Errorf("unknown HID RPC message type: %T", params)
}
if err != nil {
logger.Warn().Err(err).Msg("failed to marshal HID RPC message")
return
}
if message == nil {
logger.Warn().Msg("failed to marshal HID RPC message")
return
}
if err := session.HidChannel.Send(message); err != nil {
logger.Warn().Err(err).Msg("failed to send HID RPC message")
}
}
func (s *Session) reportHidRPCKeyboardLedState(state usbgadget.KeyboardState) {
if !s.hidRPCAvailable {
writeJSONRPCEvent("keyboardLedState", state, s)
}
reportHidRPC(state, s)
}
func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
if !s.hidRPCAvailable {
writeJSONRPCEvent("keysDownState", state, s)
}
reportHidRPC(state, s)
}

View File

@ -16,22 +16,22 @@ import (
type FieldConfig struct {
Name string
Required bool
RequiredIf map[string]interface{}
RequiredIf map[string]any
OneOf []string
ValidateTypes []string
Defaults interface{}
Defaults any
IsEmpty bool
CurrentValue interface{}
CurrentValue any
TypeString string
Delegated bool
shouldUpdateValue bool
}
func SetDefaultsAndValidate(config interface{}) error {
func SetDefaultsAndValidate(config any) error {
return setDefaultsAndValidate(config, true)
}
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
func setDefaultsAndValidate(config any, isRoot bool) error {
// first we need to check if the config is a pointer
if reflect.TypeOf(config).Kind() != reflect.Ptr {
return fmt.Errorf("config is not a pointer")
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]interface{}),
RequiredIf: make(map[string]any),
CurrentValue: fieldValue.Interface(),
IsEmpty: false,
TypeString: fieldType,
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// now check if the field has required_if
requiredIf := field.Tag.Get("required_if")
if requiredIf != "" {
requiredIfParts := strings.Split(requiredIf, ",")
for _, part := range requiredIfParts {
requiredIfParts := strings.SplitSeq(requiredIf, ",")
for part := range requiredIfParts {
partVal := strings.SplitN(part, "=", 2)
if len(partVal) != 2 {
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
@ -168,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
return nil
}
func validateFields(config interface{}, fields map[string]FieldConfig) error {
func validateFields(config any, fields map[string]FieldConfig) error {
// now we can start to validate the fields
for _, fieldConfig := range fields {
if err := fieldConfig.validate(fields); err != nil {
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil
}
func (f *FieldConfig) populate(config interface{}) {
func (f *FieldConfig) populate(config any) {
// update the field if it's not empty
if !f.shouldUpdateValue {
return

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",")
}
func toString(v interface{}) (string, error) {
func toString(v any) (string, error) {
switch v := v.(type) {
case string:
return v, nil

100
internal/hidrpc/hidrpc.go Normal file
View File

@ -0,0 +1,100 @@
package hidrpc
import (
"fmt"
"github.com/jetkvm/kvm/internal/usbgadget"
)
// MessageType is the type of the HID RPC message
type MessageType byte
const (
TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
)
const (
Version byte = 0x01 // Version of the HID RPC protocol
)
// GetQueueIndex returns the index of the queue to which the message should be enqueued.
func GetQueueIndex(messageType MessageType) int {
switch messageType {
case TypeHandshake:
return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState:
return 1
case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2
default:
return 3
}
}
// Unmarshal unmarshals the HID RPC message from the data.
func Unmarshal(data []byte, message *Message) error {
l := len(data)
if l < 1 {
return fmt.Errorf("invalid data length: %d", l)
}
message.t = MessageType(data[0])
message.d = data[1:]
return nil
}
// Marshal marshals the HID RPC message to the data.
func Marshal(message *Message) ([]byte, error) {
if message.t == 0 {
return nil, fmt.Errorf("invalid message type: %d", message.t)
}
data := make([]byte, len(message.d)+1)
data[0] = byte(message.t)
copy(data[1:], message.d)
return data, nil
}
// NewHandshakeMessage creates a new handshake message.
func NewHandshakeMessage() *Message {
return &Message{
t: TypeHandshake,
d: []byte{Version},
}
}
// NewKeyboardReportMessage creates a new keyboard report message.
func NewKeyboardReportMessage(keys []byte, modifier uint8) *Message {
return &Message{
t: TypeKeyboardReport,
d: append([]byte{modifier}, keys...),
}
}
// NewKeyboardLedMessage creates a new keyboard LED message.
func NewKeyboardLedMessage(state usbgadget.KeyboardState) *Message {
return &Message{
t: TypeKeyboardLedState,
d: []byte{state.Byte()},
}
}
// NewKeydownStateMessage creates a new keydown state message.
func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
data := make([]byte, len(state.Keys)+1)
data[0] = state.Modifier
copy(data[1:], state.Keys)
return &Message{
t: TypeKeydownState,
d: data,
}
}

133
internal/hidrpc/message.go Normal file
View File

@ -0,0 +1,133 @@
package hidrpc
import (
"fmt"
)
// Message ..
type Message struct {
t MessageType
d []byte
}
// Marshal marshals the message to a byte array.
func (m *Message) Marshal() ([]byte, error) {
return Marshal(m)
}
func (m *Message) Type() MessageType {
return m.t
}
func (m *Message) String() string {
switch m.t {
case TypeHandshake:
return "Handshake"
case TypeKeypressReport:
if len(m.d) < 2 {
return fmt.Sprintf("KeypressReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeypressReport{Key: %d, Press: %v}", m.d[0], m.d[1] == uint8(1))
case TypeKeyboardReport:
if len(m.d) < 2 {
return fmt.Sprintf("KeyboardReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardReport{Modifier: %d, Keys: %v}", m.d[0], m.d[1:])
case TypePointerReport:
if len(m.d) < 9 {
return fmt.Sprintf("PointerReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("PointerReport{X: %d, Y: %d, Button: %d}", m.d[0:4], m.d[4:8], m.d[8])
case TypeMouseReport:
if len(m.d) < 3 {
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])
default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
}
}
// KeypressReport ..
type KeypressReport struct {
Key byte
Press bool
}
// KeypressReport returns the keypress report from the message.
func (m *Message) KeypressReport() (KeypressReport, error) {
if m.t != TypeKeypressReport {
return KeypressReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
return KeypressReport{
Key: m.d[0],
Press: m.d[1] == uint8(1),
}, nil
}
// KeyboardReport ..
type KeyboardReport struct {
Modifier byte
Keys []byte
}
// KeyboardReport returns the keyboard report from the message.
func (m *Message) KeyboardReport() (KeyboardReport, error) {
if m.t != TypeKeyboardReport {
return KeyboardReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
return KeyboardReport{
Modifier: m.d[0],
Keys: m.d[1:],
}, nil
}
// PointerReport ..
type PointerReport struct {
X int
Y int
Button uint8
}
func toInt(b []byte) int {
return int(b[0])<<24 + int(b[1])<<16 + int(b[2])<<8 + int(b[3])<<0
}
// PointerReport returns the point report from the message.
func (m *Message) PointerReport() (PointerReport, error) {
if m.t != TypePointerReport {
return PointerReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
if len(m.d) != 9 {
return PointerReport{}, fmt.Errorf("invalid message length: %d", len(m.d))
}
return PointerReport{
X: toInt(m.d[0:4]),
Y: toInt(m.d[4:8]),
Button: uint8(m.d[8]),
}, nil
}
// MouseReport ..
type MouseReport struct {
DX int8
DY int8
Button uint8
}
// MouseReport returns the mouse report from the message.
func (m *Message) MouseReport() (MouseReport, error) {
if m.t != TypeMouseReport {
return MouseReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
return MouseReport{
DX: int8(m.d[0]),
DY: int8(m.d[1]),
Button: uint8(m.d[2]),
}, nil
}

View File

@ -50,7 +50,7 @@ var (
TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string {
FormatPartValueByName: func(value any, name string) string {
val := fmt.Sprintf("%s", value)
if name == "component" {
if value == nil {
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
continue
}
scopes := strings.Split(strings.ToLower(env), ",")
for _, scope := range scopes {
scopes := strings.SplitSeq(strings.ToLower(env), ",")
for scope := range scopes {
l.scopeLevels[scope] = level
}
}

View File

@ -13,32 +13,32 @@ type pionLogger struct {
func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg)
}
func (c pionLogger) Tracef(format string, args ...interface{}) {
func (c pionLogger) Tracef(format string, args ...any) {
c.logger.Trace().Msgf(format, args...)
}
func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg)
}
func (c pionLogger) Debugf(format string, args ...interface{}) {
func (c pionLogger) Debugf(format string, args ...any) {
c.logger.Debug().Msgf(format, args...)
}
func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg)
}
func (c pionLogger) Infof(format string, args ...interface{}) {
func (c pionLogger) Infof(format string, args ...any) {
c.logger.Info().Msgf(format, args...)
}
func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg)
}
func (c pionLogger) Warnf(format string, args ...interface{}) {
func (c pionLogger) Warnf(format string, args ...any) {
c.logger.Warn().Msgf(format, args...)
}
func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg)
}
func (c pionLogger) Errorf(format string, args ...interface{}) {
func (c pionLogger) Errorf(format string, args ...any) {
c.logger.Error().Msgf(format, args...)
}

View File

@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
return &defaultLogger
}
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
// TODO: move rootLogger to logging package
if l == nil {
l = &defaultLogger

View File

@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error {
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for _, line := range strings.Split(string(lines), "\n") {
for line := range strings.SplitSeq(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true
line = hostLine

View File

@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
return &t
}
func IsSame(a, b interface{}) bool {
func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a)
if err != nil {
return false

View File

@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for _, line := range strings.Split(str, "\n") {
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for _, ipStr := range strings.Fields(value) {
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue

View File

@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
}
func (c *DHCPClient) getWatchPaths() []string {
watchPaths := make(map[string]interface{})
watchPaths := make(map[string]any)
watchPaths[filepath.Dir(c.leaseFile)] = nil
if c.pidFile != "" {

View File

@ -1,3 +1,7 @@
package usbgadget
import "time"
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
const hidWriteTimeout = 10 * time.Millisecond

View File

@ -1,10 +1,10 @@
package usbgadget
import (
"bytes"
"context"
"fmt"
"os"
"reflect"
"time"
)
@ -61,6 +61,8 @@ var keyboardReportDesc = []byte{
const (
hidReadBufferSize = 8
hidKeyBufferSize = 6
hidErrorRollOver = 0x01
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0
@ -68,7 +70,9 @@ const (
KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
// power on/off LED is 5
KeyboardLedMaskShift = 1 << 6
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift
)
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
@ -81,6 +85,13 @@ type KeyboardState struct {
ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"`
Kana bool `json:"kana"`
Shift bool `json:"shift"` // This is not part of the main USB HID spec
raw byte
}
// Byte returns the raw byte representation of the keyboard state.
func (k *KeyboardState) Byte() byte {
return k.raw
}
func getKeyboardState(b byte) KeyboardState {
@ -91,27 +102,28 @@ func getKeyboardState(b byte) KeyboardState {
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 0,
Shift: b&KeyboardLedMaskShift != 0,
raw: b,
}
}
func (u *UsbGadget) updateKeyboardState(b byte) {
func (u *UsbGadget) updateKeyboardState(state byte) {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if b&^ValidKeyboardLedMasks != 0 {
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
if state&^ValidKeyboardLedMasks != 0 {
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits")
return
}
newState := getKeyboardState(b)
if reflect.DeepEqual(u.keyboardState, newState) {
if u.keyboardState == state {
return
}
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
u.keyboardState = newState
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
u.keyboardState = state
if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState)
(*u.onKeyboardStateChange)(getKeyboardState(state))
}
}
@ -123,7 +135,42 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keyboardState
return getKeyboardState(u.keyboardState)
}
func (u *UsbGadget) GetKeysDownState() KeysDownState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keysDownState
}
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("acquiring keyboardStateLock for updateKeyDownState")
// this is intentional to unlock keyboard state lock before onKeysDownChange callback
{
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
return // No change in key down state
}
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
u.keysDownState = state
}
if u.onKeysDownChange != nil {
u.log.Trace().Interface("state", state).Msg("calling onKeysDownChange")
(*u.onKeysDownChange)(state)
u.log.Trace().Interface("state", state).Msg("onKeysDownChange called")
}
}
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
}
func (u *UsbGadget) listenKeyboardEvents() {
@ -142,7 +189,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
l.Info().Msg("context done")
return
default:
l.Trace().Msg("reading from keyboard")
l.Trace().Msg("reading from keyboard for LED state changes")
if u.keyboardHidFile == nil {
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs
@ -159,7 +206,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
}
u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard")
if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue
@ -195,12 +242,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile()
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
if err := u.openKeyboardHidFile(); err != nil {
return err
}
_, err := u.keyboardHidFile.Write(data)
_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
if err != nil {
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
// Keep file open on write errors to reduce I/O overhead
@ -210,22 +257,145 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
return nil
}
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
// if we just reported an error roll over, we should clear the keys
if keys[0] == hidErrorRollOver {
for i := range keys {
keys[i] = 0
}
}
downState := KeysDownState{
Modifier: modifier,
Keys: []byte(keys[:]),
}
u.updateKeyDownState(downState)
return downState
}
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
if len(keys) > 6 {
keys = keys[:6]
if len(keys) > hidKeyBufferSize {
keys = keys[:hidKeyBufferSize]
}
if len(keys) < 6 {
keys = append(keys, make([]uint8, 6-len(keys))...)
if len(keys) < hidKeyBufferSize {
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...)
}
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
return err
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
}
u.resetUserInputTime()
return nil
return u.UpdateKeysDown(modifier, keys), err
}
const (
// https://www.usb.org/sites/default/files/documents/hut1_2.pdf
// Dynamic Flags (DV)
LeftControl = 0xE0
LeftShift = 0xE1
LeftAlt = 0xE2
LeftSuper = 0xE3 // Left GUI (e.g. Windows key, Apple Command key)
RightControl = 0xE4
RightShift = 0xE5
RightAlt = 0xE6
RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key)
)
const (
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C
ModifierMaskLeftControl = 0x01
ModifierMaskRightControl = 0x10
ModifierMaskLeftShift = 0x02
ModifierMaskRightShift = 0x20
ModifierMaskLeftAlt = 0x04
ModifierMaskRightAlt = 0x40
ModifierMaskLeftSuper = 0x08
ModifierMaskRightSuper = 0x80
)
// KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup
var KeyCodeToMaskMap = map[byte]byte{
LeftControl: ModifierMaskLeftControl,
LeftShift: ModifierMaskLeftShift,
LeftAlt: ModifierMaskLeftAlt,
LeftSuper: ModifierMaskLeftSuper,
RightControl: ModifierMaskRightControl,
RightShift: ModifierMaskRightShift,
RightAlt: ModifierMaskRightAlt,
RightSuper: ModifierMaskRightSuper,
}
func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
// 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
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the client/browser-side code in useKeyboard.ts so make sure to keep
// them in sync.
var state = u.keysDownState
modifier := state.Modifier
keys := append([]byte(nil), state.Keys...)
if mask, exists := KeyCodeToMaskMap[key]; exists {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if press {
modifier |= mask
} else {
modifier &^= mask
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
overrun := true
for i := range hidKeyBufferSize {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// 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 press {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if keys[i] != 0 {
copy(keys[i:], keys[i+1:])
keys[hidKeyBufferSize-1] = 0 // Clear the last byte
}
}
overrun = false // We found a slot for the key
break
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if overrun {
if press {
u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added")
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
for i := range keys {
keys[i] = hidErrorRollOver
}
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release")
}
}
}
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err
}

View File

@ -74,7 +74,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
}
}
_, err := u.absMouseHidFile.Write(data)
_, err := u.writeWithTimeout(u.absMouseHidFile, data)
if err != nil {
u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
// Keep file open on write errors to reduce I/O overhead
@ -84,17 +84,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil
}
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1
buttons, // Buttons
uint8(x), // X Low Byte
uint8(x >> 8), // X High Byte
uint8(y), // Y Low Byte
uint8(y >> 8), // Y High Byte
1, // Report ID 1
buttons, // Buttons
byte(x), // X Low Byte
byte(x >> 8), // X High Byte
byte(y), // Y Low Byte
byte(y >> 8), // Y High Byte
})
if err != nil {
return err

View File

@ -64,7 +64,7 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
}
}
_, err := u.relMouseHidFile.Write(data)
_, err := u.writeWithTimeout(u.relMouseHidFile, data)
if err != nil {
u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
// Keep file open on write errors to reduce I/O overhead
@ -74,15 +74,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
return nil
}
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error {
u.relMouseLock.Lock()
defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons
uint8(mx), // X
uint8(my), // Y
0, // Wheel
buttons, // Buttons
byte(mx), // X
byte(my), // Y
0, // Wheel
})
if err != nil {
return err

View File

@ -42,6 +42,11 @@ var defaultUsbGadgetDevices = Devices{
MassStorage: true,
}
type KeysDownState struct {
Modifier byte `json:"modifier"`
Keys ByteSlice `json:"keys"`
}
// UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct {
name string
@ -61,7 +66,9 @@ type UsbGadget struct {
relMouseHidFile *os.File
relMouseLock sync.Mutex
keyboardState KeyboardState
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
keyboardStateLock sync.Mutex
keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc
@ -78,6 +85,7 @@ type UsbGadget struct {
txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState)
onKeysDownChange *func(state KeysDownState)
log *zerolog.Logger
@ -183,7 +191,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel,
keyboardState: KeyboardState{},
keyboardState: 0,
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,

View File

@ -2,14 +2,43 @@ package usbgadget
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
)
type ByteSlice []byte
func (s ByteSlice) MarshalJSON() ([]byte, error) {
vals := make([]int, len(s))
for i, v := range s {
vals[i] = int(v)
}
return json.Marshal(vals)
}
func (s *ByteSlice) UnmarshalJSON(data []byte) error {
var vals []int
if err := json.Unmarshal(data, &vals); err != nil {
return err
}
*s = make([]byte, len(vals))
for i, v := range vals {
if v < 0 || v > 255 {
return fmt.Errorf("value %d out of byte range", v)
}
(*s)[i] = byte(v)
}
return nil
}
func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...)
@ -81,7 +110,32 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
return false
}
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
return -1, err
}
n, err = file.Write(data)
if err == nil {
return
}
if errors.Is(err, os.ErrDeadlineExceeded) {
u.logWithSuppression(
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
1000,
u.log,
err,
"write timed out: %s",
file.Name(),
)
err = nil
}
return
}
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {
u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock()

View File

@ -13,6 +13,7 @@ import (
"time"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/audio"
@ -22,23 +23,23 @@ import (
// Direct RPC message handling for optimal input responsiveness
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
ID interface{} `json:"id,omitempty"`
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params map[string]any `json:"params,omitempty"`
ID any `json:"id,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"`
ID interface{} `json:"id"`
JSONRPC string `json:"jsonrpc"`
Result any `json:"result,omitempty"`
Error any `json:"error,omitempty"`
ID any `json:"id"`
}
type JSONRPCEvent struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type DisplayRotationSettings struct {
@ -64,7 +65,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
}
}
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
func writeJSONRPCEvent(event string, params any, session *Session) {
request := JSONRPCEvent{
JSONRPC: "2.0",
Method: event,
@ -85,7 +86,7 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
Str("data", requestString).
Logger()
scopedLogger.Info().Msg("sending JSONRPC event")
scopedLogger.Trace().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
if err != nil {
@ -105,7 +106,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
Error: map[string]any{
"code": -32700,
"message": "Parse error",
},
@ -159,7 +160,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if !ok {
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
Error: map[string]any{
"code": -32601,
"message": "Method not found",
},
@ -169,13 +170,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Msg("Calling RPC handler")
result, err := callRPCHandler(handler, request.Params)
result, err := callRPCHandler(scopedLogger, handler, request.Params)
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
Error: map[string]any{
"code": -32603,
"message": "Internal error",
"data": err.Error(),
@ -236,7 +236,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
func rpcSetStreamQualityFactor(factor float64) error {
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor})
if err != nil {
return err
}
@ -276,7 +276,7 @@ func rpcSetEDID(edid string) error {
} else {
logger.Info().Str("edid", edid).Msg("Setting EDID")
}
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
_, err := CallCtrlAction("set_edid", map[string]any{"edid": edid})
if err != nil {
return err
}
@ -503,12 +503,12 @@ func rpcSetTLSState(state TLSState) error {
}
type RPCHandler struct {
Func interface{}
Func any
Params []string
}
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
// Use defer to recover from a panic
defer func() {
if r := recover(); r != nil {
@ -522,11 +522,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i
}()
// Call the handler
result, err = riskyCallRPCHandler(handler, params)
return result, err
result, err = riskyCallRPCHandler(logger, handler, params)
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
}
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
@ -535,20 +535,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
}
numParams := handlerType.NumIn()
args := make([]reflect.Value, numParams)
// Get the parameter names from the RPCHandler
paramNames := handler.Params
paramNames := handler.Params // Get the parameter names from the RPCHandler
if len(paramNames) != numParams {
return nil, errors.New("mismatch between handler parameters and defined parameter names")
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
return nil, err
}
for i := 0; i < numParams; i++ {
args := make([]reflect.Value, numParams)
for i := range numParams {
paramType := handlerType.In(i)
paramName := paramNames[i]
paramValue, ok := params[paramName]
if !ok {
return nil, errors.New("missing parameter: " + paramName)
err := fmt.Errorf("missing parameter: %s", paramName)
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
return nil, err
}
convertedValue := reflect.ValueOf(paramValue)
@ -565,7 +569,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
intValue := int(elemValue.Float())
if intValue < 0 || intValue > 255 {
return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
}
newSlice.Index(j).SetUint(uint64(intValue))
} else {
@ -581,12 +585,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
jsonData, err := json.Marshal(convertedValue.Interface())
if err != nil {
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
}
newStruct := reflect.New(paramType).Interface()
if err := json.Unmarshal(jsonData, newStruct); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
}
args[i] = reflect.ValueOf(newStruct).Elem()
} else {
@ -597,6 +601,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
}
}
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args)
if len(results) == 0 {
@ -604,23 +609,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
}
if len(results) == 1 {
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if !results[0].IsNil() {
return nil, results[0].Interface().(error)
if ok, err := asError(results[0]); ok {
return nil, err
}
return results[0].Interface(), nil
}
if len(results) == 2 {
if ok, err := asError(results[1]); ok {
if err != nil {
return nil, err
}
return nil, nil
}
return results[0].Interface(), nil
}
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if !results[1].IsNil() {
return nil, results[1].Interface().(error)
}
return results[0].Interface(), nil
}
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
}
return nil, errors.New("unexpected return values from handler")
func asError(value reflect.Value) (bool, error) {
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if value.IsNil() {
return true, nil
}
return true, value.Interface().(error)
}
return false, nil
}
func rpcSetMassStorageMode(mode string) (string, error) {
@ -1130,7 +1144,7 @@ func rpcSetKeyboardLayout(layout string) error {
return nil
}
func getKeyboardMacros() (interface{}, error) {
func getKeyboardMacros() (any, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
@ -1138,10 +1152,10 @@ func getKeyboardMacros() (interface{}, error) {
}
type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"`
Macros []any `json:"macros"`
}
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}
@ -1149,7 +1163,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{})
macroMap, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}
@ -1167,16 +1181,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
}
steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
if stepsArray, ok := macroMap["steps"].([]any); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{})
stepMap, ok := stepItem.(map[string]any)
if !ok {
continue
}
step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
if keysArray, ok := stepMap["keys"].([]any); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
@ -1184,7 +1198,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
}
}
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
if modsArray, ok := stepMap["modifiers"].([]any); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
@ -1254,6 +1268,8 @@ var rpcHandlers = map[string]RPCHandler{
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
@ -1294,7 +1310,6 @@ var rpcHandlers = map[string]RPCHandler{
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},

3
log.go
View File

@ -5,7 +5,7 @@ import (
"github.com/rs/zerolog"
)
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
return logging.ErrorfL(l, format, err, args...)
}
@ -19,6 +19,7 @@ var (
nbdLogger = logging.GetSubsystemLogger("nbd")
timesyncLogger = logging.GetSubsystemLogger("timesync")
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")
hidRPCLogger = logging.GetSubsystemLogger("hidrpc")
watchdogLogger = logging.GetSubsystemLogger("watchdog")
websecureLogger = logging.GetSubsystemLogger("websecure")
otaLogger = logging.GetSubsystemLogger("ota")

129
native.go
View File

@ -3,34 +3,57 @@
package kvm
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/jetkvm/kvm/resource"
)
type nativeOutput struct {
logger *zerolog.Logger
var ctrlSocketConn net.Conn
type CtrlAction struct {
Action string `json:"action"`
Seq int32 `json:"seq,omitempty"`
Params map[string]any `json:"params,omitempty"`
}
func (n *nativeOutput) Write(p []byte) (int, error) {
n.logger.Debug().Str("output", string(p)).Msg("native binary output")
return len(p), nil
type CtrlResponse struct {
Seq int32 `json:"seq,omitempty"`
Error string `json:"error,omitempty"`
Errno int32 `json:"errno,omitempty"`
Result map[string]any `json:"result,omitempty"`
Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
type EventHandler func(event CtrlResponse)
var seq int32 = 1
var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
)
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
cmd := exec.Command(binaryPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
lock.Lock()
defer lock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
}
cmd.Stdout = &nativeOutput{logger: nativeLogger}
cmd.Stderr = &nativeOutput{logger: nativeLogger}
@ -142,3 +165,87 @@ func ExtractAndRunNativeBin() error {
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
return !bytes.Equal(srcHash, dstHash)
}
func getNativeSha256() ([]byte, error) {
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
return nil, err
}
return version, nil
}
func GetNativeVersion() (string, error) {
version, err := getNativeSha256()
if err != nil {
return "", err
}
return strings.TrimSpace(string(version)), nil
}
func ensureBinaryUpdated(destPath string) error {
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil {
return err
}
defer srcFile.Close()
srcHash, err := getNativeSha256()
if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
nativeLogger.Info().
Interface("hash", srcHash).
Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
return err
}
_, err = io.Copy(destFile, srcFile)
destFile.Close()
if err != nil {
return err
}
if srcHash != nil {
err = os.WriteFile(destPath+".sha256", srcHash, 0644)
if err != nil {
return err
}
}
nativeLogger.Info().Msg("jetkvm_native updated")
}
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

View File

@ -1,65 +0,0 @@
package kvm
import (
"context"
"encoding/json"
"errors"
)
type RemoteImageReader interface {
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
}
type WebRTCDiskReader struct {
}
var webRTCDiskReader WebRTCDiskReader
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
virtualMediaStateMutex.RLock()
if currentVirtualMediaState == nil {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted")
}
if currentVirtualMediaState.Source != WebRTC {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted from webrtc")
}
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
end := offset + size
if end > mountedImageSize {
end = mountedImageSize
}
req := DiskReadRequest{
Start: uint64(offset),
End: uint64(end),
}
jsonBytes, err := json.Marshal(req)
if err != nil {
return nil, err
}
if currentSession == nil || currentSession.DiskChannel == nil {
return nil, errors.New("not active session")
}
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
}
var buf []byte
for {
select {
case data := <-diskReadChan:
buf = data[16:]
case <-ctx.Done():
return nil, context.Canceled
}
if len(buf) >= int(end-offset) {
break
}
}
return buf, nil
}

View File

@ -66,6 +66,10 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
},
settings: {

View File

@ -1,7 +1,7 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app -->
<link
@ -27,7 +27,14 @@
/>
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="JetKVM" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." />
<script>
// Initial theme setup
document.documentElement.classList.toggle(

1401
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "2025.08.07.001",
"version": "2025.09.03.2100",
"type": "module",
"engines": {
"node": "22.15.0"
@ -30,7 +30,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.4",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.12",
@ -39,35 +39,35 @@
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.106",
"react-router": "^7.8.2",
"react-simple-keyboard": "^3.8.119",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10",
"recharts": "^2.15.3",
"recharts": "^3.1.2",
"tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1",
"validator": "^13.15.15",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/compat": "^1.3.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@eslint/js": "^9.34.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/semver": "^7.7.0",
"@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"@tailwindcss/vite": "^4.1.12",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/semver": "^7.7.1",
"@types/validator": "^13.15.3",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitejs/plugin-react-swc": "^4.0.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
@ -77,9 +77,9 @@
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.11",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^6.3.5",
"vite": "^7.1.4",
"vite-tsconfig-paths": "^5.1.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
ui/public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

1
ui/public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}@media (prefers-color-scheme:dark){:root{filter:none}}</style></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
ui/public/jetkvm.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,21 @@
{
"name": "JetKVM",
"short_name": "JetKVM",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#002b36",
"background_color": "#051946",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -1,8 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

View File

@ -53,17 +53,13 @@ export default function Actionbar({
microphone: MicrophoneHookReturn;
}) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
const { developerMode } = useSettingsStore();
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
@ -74,13 +70,13 @@ export default function Actionbar({
isOpen.current = open;
if (!open) {
setTimeout(() => {
setDisableFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video");
setDisableVideoFocusTrap(false);
console.debug("Popover is closing. Returning focus trap to video");
}, 0);
}
}
},
[setDisableFocusTrap],
[setDisableVideoFocusTrap],
);
// Use WebSocket-based audio events for real-time updates
@ -118,7 +114,7 @@ export default function Actionbar({
text="Paste text"
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
@ -160,7 +156,7 @@ export default function Actionbar({
);
}}
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
@ -191,7 +187,7 @@ export default function Actionbar({
theme="light"
text="Wake on LAN"
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
@ -241,7 +237,7 @@ export default function Actionbar({
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
</div>
@ -255,7 +251,7 @@ export default function Actionbar({
text="Extension"
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
@ -280,7 +276,7 @@ export default function Actionbar({
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
<div className="hidden md:block">
@ -306,7 +302,10 @@ export default function Actionbar({
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => navigateTo("/settings")}
onClick={() => {
setDisableVideoFocusTrap(true);
navigateTo("/settings")
}}
/>
</div>

View File

@ -1,4 +1,4 @@
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import { useLocation, useNavigation, useSearchParams } from "react-router";
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";

View File

@ -1,5 +1,6 @@
import React, { JSX } from "react";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
import { Link, useNavigation } from "react-router";
import type { FetcherWithComponents, LinkProps } from "react-router";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
@ -175,7 +176,7 @@ type ButtonPropsType = Pick<
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-hidden",
"group outline-hidden cursor-pointer",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);

View File

@ -1,6 +1,7 @@
import React from "react";
import clsx from "clsx";
import { FetcherWithComponents, useNavigation } from "react-router-dom";
import { useNavigation } from "react-router";
import type { FetcherWithComponents } from "react-router";
export default function Fieldset({
children,

View File

@ -1,5 +1,5 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
@ -48,7 +48,7 @@ export default function DashboardNavbar({
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
const { usbState } = useHidStore();
// for testing
//userEmail = "user@example.org";

View File

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { cx } from "@/cva.config";
import {
@ -7,64 +7,70 @@ import {
useRTCStore,
useSettingsStore,
useVideoStore,
VideoState
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useHidRpc } from "@/hooks/useHidRpc";
export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const { keysDownState } = useHidStore();
const { mouseX, mouseY, mouseMove } = useMouseStore();
const { rpcHidStatus } = useHidRpc();
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => { /* RPC data channel closed */ };
rpcDataChannel.onerror = () => { /* Error on RPC data channel */ };
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = (e: Event) =>
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const { keyboardLedState, usbState } = useHidStore();
const { isTurnServerInUse } = useRTCStore();
const { hdmiState } = useVideoStore();
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const displayKeys = useMemo(() => {
if (!showPressedKeys)
return "";
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
const activeModifierMask = keysDownState.modifier || 0;
const keysDown = keysDownState.keys || [];
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
return [...modifierNames, ...keyNames].join(", ");
}, [keysDownState, showPressedKeys]);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? (
{(debugMode && mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
@ -73,7 +79,7 @@ export default function InfoBar() {
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? (
{(debugMode && mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs">
@ -84,31 +90,30 @@ export default function InfoBar() {
</div>
) : null}
{settings.debugMode && (
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HidRPC State:</span>
<span className="text-xs">{rpcHidStatus}</span>
</div>
)}
{showPressedKeys && (
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
{displayKeys}
</h2>
</div>
)}
@ -121,23 +126,10 @@ export default function InfoBar() {
</div>
)}
{keyboardLedStateSyncAvailable ? (
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedSync !== "browser"
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
>
{keyboardLedSync === "browser" ? "Browser" : "Host"}
</div>
) : null}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.caps_lock
keyboardLedState.caps_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
@ -147,7 +139,7 @@ export default function InfoBar() {
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.num_lock
keyboardLedState.num_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
@ -157,23 +149,28 @@ export default function InfoBar() {
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.scroll_lock
keyboardLedState.scroll_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
{keyboardLedState?.compose ? (
{keyboardLedState.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Compose
</div>
) : null}
{keyboardLedState?.kana ? (
{keyboardLedState.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Kana
</div>
) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
) : null}
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";

View File

@ -1,17 +1,18 @@
import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import FieldLabel from "@/components/FieldLabel";
import Fieldset from "@/components/Fieldset";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import { MacroStepCard } from "@/components/MacroStepCard";
import {
DEFAULT_DELAY,
MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
import { KeySequence } from "@/hooks/stores";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
interface ValidationErrors {
name?: string;
@ -44,6 +45,7 @@ export function MacroForm({
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { selectedKeyboard } = useKeyboardLayout();
const showTemporaryError = (message: string) => {
setErrorMessage(message);
@ -234,6 +236,7 @@ export function MacroForm({
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
keyboard={selectedKeyboard}
/>
))}
</div>

View File

@ -1,23 +1,18 @@
import { useMemo } from "react";
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card";
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import { KeyboardLayout } from "@/keyboardLayouts";
import { keys, modifiers } from "@/keyboardMappings";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
const keyOptions = Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
}));
const modifierOptions = Object.keys(modifiers).map(modifier => ({
value: modifier,
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
@ -67,6 +62,7 @@ interface MacroStepCardProps {
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
keyboard: KeyboardLayout
}
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
@ -84,9 +80,22 @@ export function MacroStepCard({
keyQuery,
onModifierChange,
onDelayChange,
isLastStep
isLastStep,
keyboard
}: MacroStepCardProps) {
const getFilteredKeys = () => {
const { keyDisplayMap } = keyboard;
const keyOptions = useMemo(() =>
Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
})),
[keyDisplayMap]
);
const filteredKeys = useMemo(() => {
const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
@ -95,7 +104,7 @@ export function MacroStepCard({
} else {
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
}
};
}, [keyOptions, keyQuery, step.keys]);
return (
<Card className="p-4">
@ -204,7 +213,7 @@ export function MacroStepCard({
}}
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={getFilteredKeys}
options={() => filteredKeys}
disabledMessage="Max keys reached"
size="SM"
immediate

View File

@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "react-router";
import React from "react";
import Container from "@/components/Container";

View File

@ -1,6 +1,6 @@
import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
@ -65,21 +65,22 @@ function Terminal({
readonly dataChannel: RTCDataChannel;
readonly type: AvailableTerminalTypes;
}) {
const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
const isTerminalTypeEnabled = useMemo(() => {
return terminalType == type;
}, [terminalType, type]);
useEffect(() => {
setTimeout(() => {
setDisableVideoFocusTrap(enableTerminal);
setDisableVideoFocusTrap(isTerminalTypeEnabled);
}, 500);
return () => {
setDisableVideoFocusTrap(false);
};
}, [enableTerminal, setDisableVideoFocusTrap]);
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
const readyState = dataChannel.readyState;
useEffect(() => {
@ -175,9 +176,9 @@ function Terminal({
],
{
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal,
!isTerminalTypeEnabled,
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
enableTerminal,
isTerminalTypeEnabled,
},
)}
>

View File

@ -4,9 +4,7 @@ import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
import { USBStates } from "@/hooks/stores";
type StatusProps = Record<
USBStates,
@ -67,7 +65,7 @@ export default function USBStateStatus({
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupported USB state: ", state);
console.warn("Unsupported USB state: ", state);
return;
}

View File

@ -142,7 +142,7 @@ export function UsbDeviceSetting() {
);
const handlePresetChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
(e: React.ChangeEvent<HTMLSelectElement>) => {
const newPreset = e.target.value;
setSelectedPreset(newPreset);

View File

@ -101,8 +101,8 @@ export function UsbInfoSetting() {
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
);
} else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState;
console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState);
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product
: "custom";
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
);
useEffect(() => {
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
send("getDeviceID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,

View File

@ -1,58 +1,74 @@
import { useShallow } from "zustand/react/shallow";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { LuKeyboard } from "react-icons/lu";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import { Button, LinkButton } from "@components/Button";
import "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { decodeModifiers, keys, latchingKeys, modifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } =
useUiStore();
const { keyboardLedState, keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } =
useHidStore();
const { handleKeyPress, executeMacro } = useKeyboard();
const { selectedKeyboard } = useKeyboardLayout();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
const keyDisplayMap = useMemo(() => {
return selectedKeyboard.keyDisplayMap;
}, [selectedKeyboard]);
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const virtualKeyboard = useMemo(() => {
return selectedKeyboard.virtualKeyboard;
}, [selectedKeyboard]);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const { isShiftActive } = useMemo(() => {
return decodeModifiers(keysDownState.modifier);
}, [keysDownState]);
const isCapsLockActive = useMemo(() => {
return keyboardLedState.caps_lock;
}, [keyboardLedState]);
const mainLayoutName = useMemo(() => {
// if you have the CapsLock "latched", then the shift state is inverted
const effectiveShift = isCapsLockActive ? false === isShiftActive : isShiftActive;
return effectiveShift ? "shift" : "default";
}, [isCapsLockActive, isShiftActive]);
const keyNamesForDownKeys = useMemo(() => {
const activeModifierMask = keysDownState.modifier || 0;
const modifierNames = Object.entries(modifiers)
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name, _]) => name);
const keysDown = keysDownState.keys || [];
const keyNames = Object.entries(keys)
.filter(([_, value]) => keysDown.includes(value))
.map(([name, _]) => name);
return [...modifierNames, ...keyNames, " "]; // we have to have at least one space to avoid keyboard whining
}, [keysDownState]);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
@ -97,6 +113,9 @@ function KeyboardWrapper() {
}, []);
useEffect(() => {
// Is the keyboard detached or attached?
if (isAttachedVirtualKeyboardVisible) return;
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
@ -121,96 +140,76 @@ function KeyboardWrapper() {
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
}, [isAttachedVirtualKeyboardVisible, endDrag, onDrag, startDrag]);
const onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
}, []);
const onKeyDown = useCallback(
(key: string) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
async (key: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
// handle the fake key-macros we have defined for common combinations
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([
{ keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 },
]);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([
{ keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 },
]);
return;
}
if (key === "CtrlAltBackspace") {
sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([
{ keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 },
]);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
// if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer)
if (latchingKeys.includes(key)) {
console.debug(`Latching key pressed: ${key} sending down and delayed up pair`);
handleKeyPress(keys[key], true);
setTimeout(() => handleKeyPress(keys[key], false), 100);
return;
}
// Handle caps lock state change
if (isKeyCaps && !isKeyboardLedManagedByHost) {
setIsCapsLockActive(!isCapsLockActive);
// if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again
if (Object.keys(modifiers).includes(key)) {
const currentlyDown = keyNamesForDownKeys.includes(key);
console.debug(
`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`,
);
handleKeyPress(keys[key], !currentlyDown);
return;
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
// otherwise, just treat it as a down+up pair
const cleanKey = key.replace(/[()]/g, "");
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
handleKeyPress(keys[cleanKey], true);
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
},
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
[executeMacro, handleKeyPress, keyNamesForDownKeys],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
}}
>
<AnimatePresence>
{virtualKeyboard && (
{isVirtualKeyboardEnabled && (
<motion.div
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
@ -222,51 +221,62 @@ function KeyboardWrapper() {
>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
!isAttachedVirtualKeyboardVisible
? "fixed top-0 left-0 z-10 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
...(!isAttachedVirtualKeyboardVisible
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
"rounded-none": isAttachedVirtualKeyboardVisible,
"keyboard-detached": !isAttachedVirtualKeyboardVisible,
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-4 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? (
{isAttachedVirtualKeyboardVisible ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<div className="absolute right-2 flex items-center gap-x-2">
<div className="hidden md:flex gap-x-2 items-center">
<LinkButton
size="XS"
to="settings/keyboard"
theme="light"
text={selectedKeyboard.name}
LeadingIcon={LuKeyboard}
/>
<div className="h-[20px] w-px bg-slate-800/20 dark:bg-slate-200/20" />
</div>
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
onClick={() => setVirtualKeyboardEnabled(false)}
/>
</div>
</div>
@ -275,66 +285,61 @@ function KeyboardWrapper() {
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
layoutName={mainLayoutName}
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
},
{
class: "down-key",
buttons: keyNamesForDownKeys.join(" "),
},
]}
display={keyDisplayMap}
layout={{
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
layout={virtualKeyboard.main}
disableButtonHold={true}
syncInstanceInputs={true}
debug={false}
enableLayoutCandidates={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName={layoutName}
layoutName="default"
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap}
layout={{
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
}}
syncInstanceInputs={true}
debug={false}
layout={virtualKeyboard.control}
disableButtonHold={true}
enableLayoutCandidates={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
syncInstanceInputs={true}
debug={false}
layout={virtualKeyboard.arrows}
disableButtonHold={true}
enableLayoutCandidates={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/>
</div>
{/* TODO add optional number pad */}
</div>
</div>
</Card>

View File

@ -7,16 +7,14 @@ import MacroBar from "@/components/MacroBar";
import InfoBar from "@components/InfoBar";
import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import { keys } from "@/keyboardMappings";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import useMouse from "@/hooks/useMouse";
import {
HDMIErrorOverlay,
@ -53,15 +51,22 @@ interface WebRTCVideoProps {
export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { handleKeyPress, resetKeyboardState } = useKeyboard();
const {
getRelMouseMoveHandler,
getAbsMouseMoveHandler,
getMouseWheelHandler,
resetMousePosition,
} = useMouse();
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
@ -69,49 +74,33 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
height: videoHeight,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
hdmiState,
} = useVideoStore();
// Video enhancement settings
const videoSaturation = useSettingsStore(state => state.videoSaturation);
const videoBrightness = useSettingsStore(state => state.videoBrightness);
const videoContrast = useSettingsStore(state => state.videoContrast);
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const { peerConnection } = useRTCStore();
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying;
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
// Misc states and hooks
const { send } = useJsonRpc();
// Video-related
const handleResize = useCallback(
({ width, height }: { width: number | undefined; height: number | undefined }) => {
if (!videoElm.current) return;
// Do something with width and height, e.g.:
setVideoClientSize(width || 0, height || 0);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
},
[setVideoClientSize, setVideoSize]
);
useResizeObserver({
ref: videoElm as React.RefObject<HTMLElement>,
onResize: ({ width, height }) => {
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
onResize: handleResize,
});
const updateVideoSizeStore = useCallback(
@ -132,15 +121,14 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
[updateVideoSizeStore],
);
// Pointer lock and keyboard lock related
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
if (!navigator.permissions || !navigator.permissions.query) {
if (!navigator || !navigator.permissions || !navigator.permissions.query) {
return false; // if can't query permissions, assume NOT granted
}
@ -174,29 +162,31 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
if (videoElm.current === null) return;
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted && "keyboard" in navigator) {
if (isKeyboardLockGranted && navigator && "keyboard" in navigator) {
try {
// @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock();
await navigator.keyboard.lock();
setIsKeyboardLockActive(true);
} catch {
// ignore errors
}
}
}, [checkNavigatorPermissions]);
}, [checkNavigatorPermissions, setIsKeyboardLockActive]);
const releaseKeyboardLock = useCallback(async () => {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if ("keyboard" in navigator) {
try {
// @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock();
} catch {
// ignore errors
}
if (navigator && "keyboard" in navigator) {
try {
// @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock();
} catch {
// ignore errors
}
setIsKeyboardLockActive(false);
}
}, []);
}, [setIsKeyboardLockActive]);
useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return;
@ -222,7 +212,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
}, [isPointerLockPossible]);
const requestFullscreen = useCallback(async () => {
if (!isFullscreenEnabled || !videoElm.current) return;
if (!isFullscreenEnabled || !videoElm.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my
@ -246,276 +236,81 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
}
};
document.addEventListener("fullscreenchange ", handleFullscreenChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
}, [releaseKeyboardLock]);
// Mouse-related
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode],
const absMouseMoveHandler = useMemo(
() => getAbsMouseMoveHandler({
videoClientWidth,
videoClientHeight,
videoWidth,
videoHeight,
}),
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (settings.mouseMode !== "relative") return;
if (isPointerLockActive === false && isPointerLockPossible) return;
// Send mouse movement
const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons);
},
[isPointerLockActive, isPointerLockPossible, sendRelMouseMovement, settings.mouseMode],
const relMouseMoveHandler = useMemo(
() => getRelMouseMoveHandler(),
[getRelMouseMoveHandler],
);
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "absolute") return;
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition, settings.mouseMode],
);
const absMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
if (settings.mouseMode !== "absolute") return;
// Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight;
// Calculate the effective video display area
let effectiveWidth = videoClientWidth;
let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
if (videoElementAspectRatio > videoStreamAspectRatio) {
// Pillarboxing: black bars on the left and right
effectiveWidth = videoClientHeight * videoStreamAspectRatio;
offsetX = (videoClientWidth - effectiveWidth) / 2;
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
// Letterboxing: black bars on the top and bottom
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
offsetY = (videoClientHeight - effectiveHeight) / 2;
}
// Clamp mouse position within the effective video boundaries
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
// Map clamped mouse position to the video stream's coordinate system
const relativeX = (clampedX - offsetX) / effectiveWidth;
const relativeY = (clampedY - offsetY) / effectiveHeight;
// Convert to HID absolute coordinate system (0-32767 range)
const x = Math.round(relativeX * 32767);
const y = Math.round(relativeY * 32767);
// Send mouse movement
const { buttons } = e;
sendAbsMouseMovement(x, y, buttons);
},
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (settings.scrollThrottling && blockWheelEvent) {
return;
}
// Determine if the wheel event is an accel scroll value
const isAccel = Math.abs(e.deltaY) >= 100;
// Calculate the accel scroll value
const accelScrollValue = e.deltaY / 100;
// Calculate the no accel scroll value
const noAccelScrollValue = Math.sign(e.deltaY);
// Get scroll value
const scrollValue = isAccel ? accelScrollValue : noAccelScrollValue;
// Apply clamping (i.e. min and max mouse wheel hardware value)
const clampedScrollValue = Math.max(-127, Math.min(127, scrollValue));
// Invert the clamped scroll value to match expected behavior
const invertedScrollValue = -clampedScrollValue;
send("wheelReport", { wheelY: invertedScrollValue });
// Apply blocking delay based of throttling settings
if (settings.scrollThrottling && !blockWheelEvent) {
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
}
},
[send, blockWheelEvent, settings],
);
const resetMousePosition = useCallback(() => {
sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft)
//
// But intentionally do not filter out 0x40 (AltRight) to accomodate
// Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare
// itself to be an altKey. For example, the KeyboardEvent for
// Alt Gr + 2 has the following structure:
// - altKey: false
// - code: "Digit2"
// - type: [ "keydown" | "keyup" ]
//
// For context, filteredModifiers aims to keep track which modifiers
// are being pressed on the physical keyboard at any point in time.
// There is logic in the keyUpHandler and keyDownHandler to add and
// remove 0x40 (AltRight) from the list of new modifiers.
//
// But relying on the two handlers alone to track the state of the
// modifier bears the risk that the key up event for Alt Gr could
// get lost while the browser window is temporarily out of focus,
// which means the Alt Gr key state would then be "stuck". At this
// point, we would need to rely on the user to press Alt Gr again
// to properly release the state of that modifier.
.filter(modifier => altKey || modifier !== modifiers["AltLeft"])
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
const mouseWheelHandler = useMemo(
() => getMouseWheelHandler(),
[getMouseWheelHandler],
);
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
(e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (hidKey === undefined) {
console.warn(`Key down not mapped: ${code}`);
return;
}
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) {
if (e.metaKey && hidKey < 0xE0) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
handleKeyPress(hidKey, false);
}, 10);
}
console.debug(`Key down: ${hidKey}`);
handleKeyPress(hidKey, true);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
// If the left meta key was just pressed and we're not keyboard locked
// we'll never see the keyup event because the browser is going to lose
// focus so set a deferred keyup after a short delay
setTimeout(() => {
console.debug(`Forcing the left meta key release`);
handleKeyPress(hidKey, false);
}, 100);
}
},
[
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
[handleKeyPress, isKeyboardLockActive],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (hidKey === undefined) {
console.warn(`Key up not mapped: ${code}`);
return;
}
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
console.debug(`Key up: ${hidKey}`);
handleKeyPress(hidKey, false);
},
[
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
[handleKeyPress],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -526,7 +321,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current.paused) {
console.log("Force playing video");
console.debug("Force playing video");
videoElm.current.play();
}
}
@ -569,13 +364,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// We set the as early as possible
addStreamToVideoElm(mediaStream);
},
[
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
[addStreamToVideoElm, mediaStream],
);
// Setup Keyboard Events
@ -624,14 +413,16 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
function setMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
const isRelativeMouseMode = (settings.mouseMode === "relative");
const mouseHandler = isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("mousemove", mouseHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
@ -659,7 +450,16 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
abortController.abort();
};
},
[absMouseMoveHandler, isPointerLockActive, isPointerLockPossible, mouseWheelHandler, relMouseMoveHandler, requestPointerLock, resetMousePosition, settings.mouseMode],
[
isPointerLockActive,
isPointerLockPossible,
requestPointerLock,
absMouseMoveHandler,
relMouseMoveHandler,
mouseWheelHandler,
resetMousePosition,
settings.mouseMode,
],
);
const containerRef = useRef<HTMLDivElement>(null);
@ -688,10 +488,22 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
return isDefault
? {} // No filter if all settings are default (1.0)
: {
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
};
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
};
}, [videoSaturation, videoBrightness, videoContrast]);
function getAdjustedKeyCode(e: KeyboardEvent) {
const key = e.key;
let code = e.code;
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
return code;
}
return (
<div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col">
@ -724,48 +536,48 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
<PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<video
ref={videoElm}
autoPlay
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={false}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
style={videoStyle}
className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"opacity-60!": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
isPlaying,
},
)}
/>
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
<video
ref={videoElm}
autoPlay
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={false}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
style={videoStyle}
className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"opacity-60!": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
isPlaying,
},
)}
/>
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
)}
</div>
</div>
<VirtualKeyboard />

View File

@ -49,7 +49,7 @@ export function SerialConsole() {
setSettings(newSettings);
});
};
const setTerminalType = useUiStore(state => state.setTerminalType);
const { setTerminalType } = useUiStore();
return (
<div className="space-y-4">

View File

@ -1,51 +1,27 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { forwardRef, useEffect, useCallback } from "react";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const { send } = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
@ -94,42 +70,6 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
@ -202,18 +142,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
@ -10,7 +10,8 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
import { KeyStroke } from "@/keyboardLayouts";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications";
const hidKeyboardPayload = (modifier: number, keys: number[]) => {
@ -18,33 +19,24 @@ const hidKeyboardPayload = (modifier: number, keys: number[]) => {
};
const modifierCode = (shift?: boolean, altRight?: boolean) => {
return (shift ? modifiers["ShiftLeft"] : 0)
| (altRight ? modifiers["AltRight"] : 0)
return (shift ? modifiers.ShiftLeft : 0)
| (altRight ? modifiers.AltRight : 0)
}
const noModifier = 0
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { setPasteModeEnabled } = useHidStore();
const { setDisableVideoFocusTrap } = useUiStore();
const { send } = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { rpcDataChannel } = useRTCStore();
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
// this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout;
return "en_US";
}, [keyboardLayout]);
const { setKeyboardLayout } = useSettingsStore();
const { selectedKeyboard } = useKeyboardLayout();
useEffect(() => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
@ -54,24 +46,23 @@ export default function PasteModal() {
}, [send, setKeyboardLayout]);
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setPasteModeEnabled(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
}, [setDisableVideoFocusTrap, setPasteModeEnabled]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setPasteModeEnabled(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const keyboard: KeyboardLayout = selectedKeyboard(safeKeyboardLayout);
if (!keyboard) return;
if (!selectedKeyboard) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const keyprops = keyboard.chars[char];
const keyprops = selectedKeyboard.chars[char];
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
@ -111,7 +102,7 @@ export default function PasteModal() {
);
});
}
}, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]);
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
useEffect(() => {
if (TextAreaRef.current) {
@ -161,7 +152,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !selectedKeyboard(safeKeyboardLayout).chars[char]),
.filter(char => !selectedKeyboard.chars[char]),
),
];
@ -182,7 +173,7 @@ export default function PasteModal() {
</div>
<div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {selectedKeyboard(safeKeyboardLayout).name}
Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
</p>
</div>
</div>

View File

@ -14,10 +14,8 @@ import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { setDisableVideoFocusTrap } = useUiStore();
const { rpcDataChannel } = useRTCStore();
const { send } = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);

View File

@ -37,10 +37,18 @@ function createChartArray<T, K extends keyof T>(
}
export default function ConnectionStatsSidebar() {
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
const { sidebarView, setSidebarView } = useUiStore();
const {
mediaStream,
peerConnection,
inboundRtpStats,
appendInboundRtpStats,
candidatePairStats,
appendCandidatePairStats,
appendLocalCandidateStats,
appendRemoteCandidateStats,
appendDiskDataChannelStats,
} = useRTCStore();
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
const appendDiskDataChannelStats = useRTCStore(
state => state.appendDiskDataChannelStats,
);
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
const appendRemoteCandidateStats = useRTCStore(
state => state.appendRemoteCandidateStats,
);
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
useInterval(function collectWebRTCStats() {
(async () => {
if (!mediaStream) return;
@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() {
successfulLocalCandidateId = report.localCandidateId;
successfulRemoteCandidateId = report.remoteCandidateId;
}
appendIceCandidatePair(report);
appendCandidatePairStats(report);
} else if (report.type === "local-candidate") {
// We only want to append the local candidate stats that were used in nominated candidate pair
if (successfulLocalCandidateId === report.id) {

302
ui/src/hooks/hidRpc.ts Normal file
View File

@ -0,0 +1,302 @@
import { KeyboardLedState, KeysDownState } from "./stores";
export const HID_RPC_MESSAGE_TYPES = {
Handshake: 0x01,
KeyboardReport: 0x02,
PointerReport: 0x03,
WheelReport: 0x04,
KeypressReport: 0x05,
MouseReport: 0x06,
KeyboardLedState: 0x32,
KeysDownState: 0x33,
}
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
export const HID_RPC_VERSION = 0x01;
const withinUint8Range = (value: number) => {
return value >= 0 && value <= 255;
};
const fromInt32toUint8 = (n: number) => {
if (n !== n >> 0) {
throw new Error(`Number ${n} is not within the int32 range`);
}
return new Uint8Array([
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromInt8ToUint8 = (n: number) => {
if (n < -128 || n > 127) {
throw new Error(`Number ${n} is not within the int8 range`);
}
return (n >> 0) & 0xFF;
};
const keyboardLedStateMasks = {
num_lock: 1 << 0,
caps_lock: 1 << 1,
scroll_lock: 1 << 2,
compose: 1 << 3,
kana: 1 << 4,
shift: 1 << 6,
}
export class RpcMessage {
messageType: HidRpcMessageType;
constructor(messageType: HidRpcMessageType) {
this.messageType = messageType;
}
marshal(): Uint8Array {
throw new Error("Not implemented");
}
public static unmarshal(_data: Uint8Array): RpcMessage | undefined {
throw new Error("Not implemented");
}
}
export class HandshakeMessage extends RpcMessage {
version: number;
constructor(version: number) {
super(HID_RPC_MESSAGE_TYPES.Handshake);
this.version = version;
}
marshal(): Uint8Array {
return new Uint8Array([this.messageType, this.version]);
}
public static unmarshal(data: Uint8Array): HandshakeMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid handshake message length: ${data.length}`);
}
return new HandshakeMessage(data[0]);
}
}
export class KeypressReportMessage extends RpcMessage {
private _key = 0;
private _press = false;
get key(): number {
return this._key;
}
set key(value: number) {
if (!withinUint8Range(value)) {
throw new Error(`Key ${value} is not within the uint8 range`);
}
this._key = value;
}
get press(): boolean {
return this._press;
}
set press(value: boolean) {
this._press = value;
}
constructor(key: number, press: boolean) {
super(HID_RPC_MESSAGE_TYPES.KeypressReport);
this.key = key;
this.press = press;
}
marshal(): Uint8Array {
return new Uint8Array([
this.messageType,
this.key,
this.press ? 1 : 0,
]);
}
public static unmarshal(data: Uint8Array): KeypressReportMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keypress report message length: ${data.length}`);
}
return new KeypressReportMessage(data[0], data[1] === 1);
}
}
export class KeyboardReportMessage extends RpcMessage {
private _keys: number[] = [];
private _modifier = 0;
get keys(): number[] {
return this._keys;
}
set keys(value: number[]) {
value.forEach((k) => {
if (!withinUint8Range(k)) {
throw new Error(`Key ${k} is not within the uint8 range`);
}
});
this._keys = value;
}
get modifier(): number {
return this._modifier;
}
set modifier(value: number) {
if (!withinUint8Range(value)) {
throw new Error(`Modifier ${value} is not within the uint8 range`);
}
this._modifier = value;
}
constructor(keys: number[], modifier: number) {
super(HID_RPC_MESSAGE_TYPES.KeyboardReport);
this.keys = keys;
this.modifier = modifier;
}
marshal(): Uint8Array {
return new Uint8Array([
this.messageType,
this.modifier,
...this.keys,
]);
}
public static unmarshal(data: Uint8Array): KeyboardReportMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keyboard report message length: ${data.length}`);
}
return new KeyboardReportMessage(Array.from(data.slice(1)), data[0]);
}
}
export class KeyboardLedStateMessage extends RpcMessage {
keyboardLedState: KeyboardLedState;
constructor(keyboardLedState: KeyboardLedState) {
super(HID_RPC_MESSAGE_TYPES.KeyboardLedState);
this.keyboardLedState = keyboardLedState;
}
public static unmarshal(data: Uint8Array): KeyboardLedStateMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keyboard led state message length: ${data.length}`);
}
const s = data[0];
const state = {
num_lock: (s & keyboardLedStateMasks.num_lock) !== 0,
caps_lock: (s & keyboardLedStateMasks.caps_lock) !== 0,
scroll_lock: (s & keyboardLedStateMasks.scroll_lock) !== 0,
compose: (s & keyboardLedStateMasks.compose) !== 0,
kana: (s & keyboardLedStateMasks.kana) !== 0,
shift: (s & keyboardLedStateMasks.shift) !== 0,
} as KeyboardLedState;
return new KeyboardLedStateMessage(state);
}
}
export class KeysDownStateMessage extends RpcMessage {
keysDownState: KeysDownState;
constructor(keysDownState: KeysDownState) {
super(HID_RPC_MESSAGE_TYPES.KeysDownState);
this.keysDownState = keysDownState;
}
public static unmarshal(data: Uint8Array): KeysDownStateMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keys down state message length: ${data.length}`);
}
return new KeysDownStateMessage({
modifier: data[0],
keys: Array.from(data.slice(1))
});
}
}
export class PointerReportMessage extends RpcMessage {
x: number;
y: number;
buttons: number;
constructor(x: number, y: number, buttons: number) {
super(HID_RPC_MESSAGE_TYPES.PointerReport);
this.x = x;
this.y = y;
this.buttons = buttons;
}
marshal(): Uint8Array {
return new Uint8Array([
this.messageType,
...fromInt32toUint8(this.x),
...fromInt32toUint8(this.y),
this.buttons,
]);
}
}
export class MouseReportMessage extends RpcMessage {
dx: number;
dy: number;
buttons: number;
constructor(dx: number, dy: number, buttons: number) {
super(HID_RPC_MESSAGE_TYPES.MouseReport);
this.dx = dx;
this.dy = dy;
this.buttons = buttons;
}
marshal(): Uint8Array {
return new Uint8Array([
this.messageType,
fromInt8ToUint8(this.dx),
fromInt8ToUint8(this.dy),
this.buttons,
]);
}
}
export const messageRegistry = {
[HID_RPC_MESSAGE_TYPES.Handshake]: HandshakeMessage,
[HID_RPC_MESSAGE_TYPES.KeysDownState]: KeysDownStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage,
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
}
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {
if (data.length < 1) {
throw new Error(`Invalid HID RPC message length: ${data.length}`);
}
const payload = data.slice(1);
const messageType = data[0];
if (!(messageType in messageRegistry)) {
throw new Error(`Unknown HID RPC message type: ${messageType}`);
}
return messageRegistry[messageType].unmarshal(payload);
};

View File

@ -49,12 +49,12 @@ export interface User {
picture?: string;
}
interface UserState {
export interface UserState {
user: User | null;
setUser: (user: User | null) => void;
}
interface UIState {
export interface UIState {
sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void;
@ -70,21 +70,21 @@ interface UIState {
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void;
setTerminalType: (type: UIState["terminalType"]) => void;
}
export const useUiStore = create<UIState>(set => ({
terminalType: "none",
setTerminalType: type => set({ terminalType: type }),
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
sidebarView: null,
setSidebarView: view => set({ sidebarView: view }),
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }),
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view =>
set(state => {
@ -96,19 +96,22 @@ export const useUiStore = create<UIState>(set => ({
}),
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled =>
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
}));
interface RTCState {
export interface RTCState {
peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null;
diskChannel: RTCDataChannel | null;
setDiskChannel: (channel: RTCDataChannel) => void;
rpcHidProtocolVersion: number | null;
setRpcHidProtocolVersion: (version: number) => void;
rpcHidChannel: RTCDataChannel | null;
setRpcHidChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
@ -130,18 +133,18 @@ interface RTCState {
setMicrophoneMuted: (muted: boolean) => void;
videoStreamStats: RTCInboundRtpStreamStats | null;
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void;
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void;
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>;
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void;
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void;
clearCandidatePairStats: () => void;
// Remote ICE candidates stat type doesn't exist as of today
@ -153,7 +156,7 @@ interface RTCState {
// Disk data channel stats type doesn't exist as of today
diskDataChannelStats: Map<number, RTCDataChannelStats>;
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void;
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void;
terminalChannel: RTCDataChannel | null;
setTerminalChannel: (channel: RTCDataChannel) => void;
@ -161,22 +164,25 @@ interface RTCState {
export const useRTCStore = create<RTCState>(set => ({
peerConnection: null,
setPeerConnection: pc => set({ peerConnection: pc }),
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null,
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number) => set({ rpcHidProtocolVersion: version }),
rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }),
transceiver: null,
setTransceiver: transceiver => set({ transceiver }),
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }),
diskChannel: null,
setDiskChannel: channel => set({ diskChannel: channel }),
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
mediaStream: null,
setMediaStream: stream => set({ mediaStream: stream }),
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
// Microphone stream management
microphoneStream: null,
@ -189,60 +195,60 @@ export const useRTCStore = create<RTCState>(set => ({
setMicrophoneMuted: muted => set({ isMicrophoneMuted: muted }),
videoStreamStats: null,
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(),
isTurnServerInUse: false,
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(),
appendInboundRtpStats: newStat => {
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
set(prevState => ({
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats),
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
}));
},
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(),
appendCandidatePairStats: newStat => {
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
set(prevState => ({
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats),
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
}));
},
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(),
appendLocalCandidateStats: newStat => {
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats),
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
}));
},
remoteCandidateStats: new Map(),
appendRemoteCandidateStats: newStat => {
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats),
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
}));
},
diskDataChannelStats: new Map(),
appendDiskDataChannelStats: newStat => {
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => {
set(prevState => ({
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats),
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
}));
},
// Add these new properties to the store implementation
terminalChannel: null,
setTerminalChannel: channel => set({ terminalChannel: channel }),
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }),
}));
interface MouseMove {
export interface MouseMove {
x: number;
y: number;
buttons: number;
}
interface MouseState {
export interface MouseState {
mouseX: number;
mouseY: number;
mouseMove?: MouseMove;
@ -254,9 +260,17 @@ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0,
mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }),
}));
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
export interface HdmiState {
ready: boolean;
error?: HdmiErrorStates;
}
export interface VideoState {
width: number;
height: number;
@ -264,19 +278,13 @@ export interface VideoState {
clientHeight: number;
setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void;
hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
hdmiState: HdmiStates;
setHdmiState: (state: {
ready: boolean;
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
error?: HdmiErrorStates;
}) => void;
}
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
export const useVideoStore = create<VideoState>(set => ({
width: 0,
height: 0,
@ -285,13 +293,13 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0,
// The video element's client size
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }),
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }),
// Resolution
setSize: (width, height) => set({ width, height }),
setSize: (width: number, height: number) => set({ width, height }),
hdmiState: "connecting",
setHdmiState: state => {
setHdmiState: (state: HdmiState) => {
if (!state) return;
const { ready, error } = state;
@ -305,9 +313,13 @@ export const useVideoStore = create<VideoState>(set => ({
},
}));
export type KeyboardLedSync = "auto" | "browser" | "host";
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
interface SettingsState {
export interface SettingsState {
isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void;
@ -330,9 +342,6 @@ interface SettingsState {
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
scrollThrottling: number;
setScrollThrottling: (value: number) => void;
@ -352,17 +361,17 @@ export const useSettingsStore = create(
persist<SettingsState>(
set => ({
isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }),
mouseMode: "absolute",
setMouseMode: mode => set({ mouseMode: mode }),
setMouseMode: (mode: string) => set({ mouseMode: mode }),
debugMode: import.meta.env.DEV,
setDebugMode: enabled => set({ debugMode: enabled }),
setDebugMode: (enabled: boolean) => set({ debugMode: enabled }),
// Add developer mode with default value
developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }),
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
@ -376,24 +385,21 @@ export const useSettingsStore = create(
set({ backlightSettings: settings }),
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }),
scrollThrottling: 0,
setScrollThrottling: value => set({ scrollThrottling: value }),
setScrollThrottling: (value: number) => set({ scrollThrottling: value }),
showPressedKeys: true,
setShowPressedKeys: show => set({ showPressedKeys: show }),
setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }),
// Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0,
setVideoSaturation: value => set({ videoSaturation: value }),
setVideoSaturation: (value: number) => set({ videoSaturation: value }),
videoBrightness: 1.0,
setVideoBrightness: value => set({ videoBrightness: value }),
setVideoBrightness: (value: number) => set({ videoBrightness: value }),
videoContrast: 1.0,
setVideoContrast: value => set({ videoContrast: value }),
setVideoContrast: (value: number) => set({ videoContrast: value }),
}),
{
name: "settings",
@ -403,7 +409,7 @@ export const useSettingsStore = create(
);
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
source: "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;
filename: string | null;
url: string | null;
@ -412,13 +418,10 @@ export interface RemoteVirtualMediaState {
}
export interface MountMediaState {
localFile: File | null;
setLocalFile: (file: MountMediaState["localFile"]) => void;
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
modalView: "mode" | "url" | "device" | "upload" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean;
@ -432,24 +435,21 @@ export interface MountMediaState {
}
export const useMountMediaStore = create<MountMediaState>(set => ({
localFile: null,
setLocalFile: file => set({ localFile: file }),
remoteVirtualMediaState: null,
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }),
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),
modalView: "mode",
setModalView: view => set({ modalView: view }),
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }),
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [],
addUploadedFile: file =>
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
errorMessage: null,
setErrorMessage: message => set({ errorMessage: message }),
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
}));
export interface KeyboardLedState {
@ -458,41 +458,30 @@ export interface KeyboardLedState {
scroll_lock: boolean;
compose: boolean;
kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED
};
const defaultKeyboardLedState: KeyboardLedState = {
num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
};
export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01;
export interface KeysDownState {
modifier: number;
keys: number[];
}
export type USBStates =
| "configured"
| "attached"
| "not attached"
| "suspended"
| "addressed";
export interface HidState {
activeKeys: number[];
activeModifiers: number[];
updateActiveKeysAndModifiers: (keysAndModifiers: {
keys: number[];
modifiers: number[];
}) => void;
altGrArmed: boolean;
setAltGrArmed: (armed: boolean) => void;
altGrTimer: number | null; // _altGrCtrlTime
setAltGrTimer: (timeout: number | null) => void;
altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void;
keyboardLedState?: KeyboardLedState;
keyboardLedState: KeyboardLedState;
setKeyboardLedState: (state: KeyboardLedState) => void;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
keyboardLedStateSyncAvailable: boolean;
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
keysDownState: KeysDownState;
setKeysDownState: (state: KeysDownState) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -500,55 +489,26 @@ export interface HidState {
isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
setUsbState: (state: HidState["usbState"]) => void;
usbState: USBStates;
setUsbState: (state: USBStates) => void;
}
export const useHidStore = create<HidState>((set, get) => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
altGrArmed: false,
setAltGrArmed: armed => set({ altGrArmed: armed }),
altGrTimer: 0,
setAltGrTimer: timeout => set({ altGrTimer: timeout }),
altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
setIsNumLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.num_lock = active;
set({ keyboardLedState });
},
setIsCapsLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.caps_lock = active;
set({ keyboardLedState });
},
setIsScrollLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.scroll_lock = active;
set({ keyboardLedState });
},
keyboardLedStateSyncAvailable: false,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }),
// Add these new properties for USB state
usbState: "not attached",
setUsbState: state => set({ usbState: state }),
setUsbState: (state: USBStates) => set({ usbState: state }),
}));
export const useUserStore = create<UserState>(set => ({
@ -556,11 +516,15 @@ export const useUserStore = create<UserState>(set => ({
setUser: user => set({ user }),
}));
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
otaState: {
export type UpdateModalViews =
| "loading"
| "updating"
| "upToDate"
| "updateAvailable"
| "updateCompleted"
| "error";
export interface OtaState {
updating: boolean;
error: string | null;
@ -589,24 +553,24 @@ export interface UpdateState {
systemUpdateProgress: number;
systemUpdatedAt: string | null;
};
setOtaState: (state: UpdateState["otaState"]) => void;
};
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
otaState: OtaState;
setOtaState: (state: OtaState) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView:
| "loading"
| "updating"
| "upToDate"
| "updateAvailable"
| "updateCompleted"
| "error";
setModalView: (view: UpdateState["modalView"]) => void;
modalView: UpdateModalViews
setModalView: (view: UpdateModalViews) => void;
setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null;
}
export const useUpdateStore = create<UpdateState>(set => ({
isUpdatePending: false,
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }),
otaState: {
@ -630,18 +594,22 @@ export const useUpdateStore = create<UpdateState>(set => ({
},
updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: view => set({ modalView: view }),
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
}));
interface UsbConfigModalState {
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
export type UsbConfigModalViews =
| "updateUsbConfig"
| "updateUsbConfigSuccess";
export interface UsbConfigModalState {
modalView: UsbConfigModalViews ;
errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void;
setModalView: (view: UsbConfigModalViews) => void;
setErrorMessage: (message: string | null) => void;
}
@ -656,24 +624,26 @@ export interface UsbConfigState {
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
}));
interface LocalAuthModalState {
modalView:
| "createPassword"
| "deletePassword"
| "updatePassword"
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
setModalView: (view: LocalAuthModalState["modalView"]) => void;
export type LocalAuthModalViews =
| "createPassword"
| "deletePassword"
| "updatePassword"
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
export interface LocalAuthModalState {
modalView:LocalAuthModalViews;
setModalView: (view:LocalAuthModalViews) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
setModalView: view => set({ modalView: view }),
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
}));
export interface DeviceState {
@ -688,8 +658,8 @@ export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null,
systemVersion: null,
setAppVersion: version => set({ appVersion: version }),
setSystemVersion: version => set({ systemVersion: version }),
setAppVersion: (version: string) => set({ appVersion: version }),
setSystemVersion: (version: string) => set({ systemVersion: version }),
}));
export interface DhcpLease {
@ -935,7 +905,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
sendFn(
"setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } },
response => {
(response: JsonRpcResponse) => {
resolve(response);
},
);

View File

@ -1,4 +1,5 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import type { NavigateOptions } from "react-router";
import { useCallback, useMemo } from "react";
import { isOnDevice } from "../main";

150
ui/src/hooks/useHidRpc.ts Normal file
View File

@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo } from "react";
import { useRTCStore } from "@/hooks/stores";
import {
HID_RPC_VERSION,
HandshakeMessage,
KeyboardReportMessage,
KeypressReportMessage,
MouseReportMessage,
PointerReportMessage,
RpcMessage,
unmarshalHidRpcMessage,
} from "./hidRpc";
export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const { rpcHidChannel, setRpcHidProtocolVersion, rpcHidProtocolVersion } = useRTCStore();
const rpcHidReady = useMemo(() => {
return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidChannel, rpcHidProtocolVersion]);
const rpcHidStatus = useMemo(() => {
if (!rpcHidChannel) return "N/A";
if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState;
if (!rpcHidProtocolVersion) return "handshaking";
return `ready (v${rpcHidProtocolVersion})`;
}, [rpcHidChannel, rpcHidProtocolVersion]);
const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => {
if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return;
let data: Uint8Array | undefined;
try {
data = message.marshal();
} catch (e) {
console.error("Failed to send HID RPC message", e);
}
if (!data) return;
rpcHidChannel?.send(data as unknown as ArrayBuffer);
}, [rpcHidChannel, rpcHidReady]);
const reportKeyboardEvent = useCallback(
(keys: number[], modifier: number) => {
sendMessage(new KeyboardReportMessage(keys, modifier));
}, [sendMessage],
);
const reportKeypressEvent = useCallback(
(key: number, press: boolean) => {
sendMessage(new KeypressReportMessage(key, press));
},
[sendMessage],
);
const reportAbsMouseEvent = useCallback(
(x: number, y: number, buttons: number) => {
sendMessage(new PointerReportMessage(x, y, buttons));
},
[sendMessage],
);
const reportRelMouseEvent = useCallback(
(dx: number, dy: number, buttons: number) => {
sendMessage(new MouseReportMessage(dx, dy, buttons));
},
[sendMessage],
);
const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return;
sendMessage(new HandshakeMessage(HID_RPC_VERSION), true);
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]);
const handleHandshake = useCallback((message: HandshakeMessage) => {
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
if (message.version > HID_RPC_VERSION) {
// we assume that the UI is always using the latest version of the HID RPC protocol
// so we can't support this
// TODO: use capabilities to determine rather than version number
console.error("Server is using a newer HID RPC version than the client", message);
return;
}
setRpcHidProtocolVersion(message.version);
}, [setRpcHidProtocolVersion]);
useEffect(() => {
if (!rpcHidChannel) return;
// send handshake message
sendHandshake();
const messageHandler = (e: MessageEvent) => {
if (typeof e.data === "string") {
console.warn("Received string data in HID RPC message handler", e.data);
return;
}
const message = unmarshalHidRpcMessage(new Uint8Array(e.data));
if (!message) {
console.warn("Received invalid HID RPC message", e.data);
return;
}
console.debug("Received HID RPC message", message);
switch (message.constructor) {
case HandshakeMessage:
handleHandshake(message as HandshakeMessage);
break;
default:
// not all events are handled here, the rest are handled by the onHidRpcMessage callback
break;
}
onHidRpcMessage?.(message);
};
rpcHidChannel.addEventListener("message", messageHandler);
return () => {
rpcHidChannel.removeEventListener("message", messageHandler);
};
},
[
rpcHidChannel,
onHidRpcMessage,
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
],
);
return {
reportKeyboardEvent,
reportKeypressEvent,
reportAbsMouseEvent,
reportRelMouseEvent,
rpcHidProtocolVersion,
rpcHidReady,
rpcHidStatus,
};
}

View File

@ -35,10 +35,10 @@ const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(
let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { rpcDataChannel } = useRTCStore();
const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return;
requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
@ -47,7 +47,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.send(JSON.stringify(payload));
},
[rpcDataChannel],
[rpcDataChannel]
);
useEffect(() => {
@ -63,7 +63,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return;
}
if ("error" in payload) devError(payload.error);
if ("error" in payload) console.error("RPC error", payload);
if (!payload.id) return;
const callback = callbackStore.get(payload.id);
@ -78,7 +78,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return () => {
rpcDataChannel.removeEventListener("message", messageHandler);
};
}, [rpcDataChannel, onRequest]);
},
[rpcDataChannel, onRequest]);
return { send };
}

View File

@ -1,42 +1,101 @@
import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { keys, modifiers } from "@/keyboardMappings";
import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() {
const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const updateActiveKeysAndModifiers = useHidStore(
state => state.updateActiveKeysAndModifiers,
);
// 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
// device-side code, we have to still support the situation where the browser/client-side code
// 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
// 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
// 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
// getKeysDownState API.
// HidRPC is a binary format for exchanging keyboard and mouse events
const {
reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc,
rpcHidReady,
} = useHidRpc((message) => {
switch (message.constructor) {
case KeysDownStateMessage:
setKeysDownState((message as KeysDownStateMessage).keysDownState);
break;
case KeyboardLedStateMessage:
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
break;
default:
break;
}
});
// sendKeyboardEvent is used to send the full keyboard state to the device for macro handling
// and resetting keyboard state. It sends the keys currently pressed and the modifier state.
// The device will respond with the keysDownState if it supports the keyPressReport API
// or just accept the state if it does not support (returning no result)
const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => {
if (rpcDataChannel?.readyState !== "open") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
send("keyboardReport", { keys, modifier: accModifier });
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
// We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
if (rpcHidReady) {
console.debug("Sending keyboard report via HidRPC");
sendKeyboardEventHidRpc(state.keys, state.modifier);
return;
}
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
}
});
},
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
[
rpcDataChannel?.readyState,
rpcHidReady,
send,
sendKeyboardEventHidRpc,
],
);
const resetKeyboardState = useCallback(() => {
sendKeyboardEvent([], []);
}, [sendKeyboardEvent]);
// 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
// is clean.
const resetKeyboardState = useCallback(
async () => {
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay.
// The keys and modifiers are pressed together and held for the delay duration.
// 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.
// 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 }[]) => {
for (const [index, step] of steps.entries()) {
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
const modifierValues = step.modifiers?.map(mod => modifiers[mod]).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);
// If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierValues.length > 0) {
sendKeyboardEvent(keyValues, modifierValues);
if (keyValues.length > 0 || modifierMask > 0) {
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask });
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
resetKeyboardState();
@ -52,5 +111,104 @@ export default function useKeyboard() {
}
};
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
// handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const handleKeyPress = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
if (rpcHidReady) {
// if the keyPress api is available, we can just send the key press event
// sendKeypressEvent is used to send a single key press/release event to the device.
// It sends the key and whether it is pressed or released.
// Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypressEventHidRpc(key, press);
} else {
// if the keyPress api is not available, we need to handle the key locally
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
sendKeyboardEvent(downState); // then we send the full state
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[
rpcHidReady,
keysDownState,
resetKeyboardState,
rpcDataChannel?.readyState,
sendKeyboardEvent,
sendKeypressEventHidRpc,
],
);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
// 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
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the device-side code in hid_keyboard.go so make sure to keep them in sync.
let modifiers = state.modifier;
const keys = state.keys;
const modifierMask = hidKeyToModifierMask[key] || 0;
if (modifierMask !== 0) {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if (press) {
modifiers |= modifierMask;
} else {
modifiers &= ~modifierMask;
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
let overrun = true;
for (let i = 0; i < hidKeyBufferSize; i++) {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// 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 (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if (keys[i] !== 0) {
keys.splice(i, 1);
keys.push(0); // add a zero at the end
}
}
overrun = false; // We found a slot for the key
break;
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver);
} else {
// 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`)
}
}
}
return { modifier: modifiers, keys };
}
return { handleKeyPress, resetKeyboardState, executeMacro };
}

View File

@ -0,0 +1,35 @@
import { useMemo } from "react";
import { useSettingsStore } from "@/hooks/stores";
import { keyboards } from "@/keyboardLayouts";
export default function useKeyboardLayout() {
const { keyboardLayout } = useSettingsStore();
const keyboardOptions = useMemo(() => {
return keyboards.map((keyboard) => {
return { label: keyboard.name, value: keyboard.isoCode }
});
}, []);
const isoCode = useMemo(() => {
// If we don't have a specific layout, default to "en-US" because that was the original layout
// developed so it is a good fallback. Additionally, we replace "en_US" with "en-US" because
// the original server-side code used "en_US" as the default value, but that's not the correct
// ISO code for English/United State. To ensure we remain backward compatible with devices that
// have not had their Keyboard Layout selected by the user, we want to treat "en_US" as if it was
// "en-US" to match the ISO standard codes now used in the keyboardLayouts.
console.debug("Current keyboard layout from store:", keyboardLayout);
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout.replace("en_US", "en-US");
return "en-US";
}, [keyboardLayout]);
const selectedKeyboard = useMemo(() => {
// fallback to original behaviour of en-US if no isoCode given or matching layout not found
return keyboards.find(keyboard => keyboard.isoCode === isoCode)
?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!;
}, [isoCode]);
return { keyboardOptions, isoCode, selectedKeyboard };
}

172
ui/src/hooks/useMouse.ts Normal file
View File

@ -0,0 +1,172 @@
import { useCallback, useState } from "react";
import { useJsonRpc } from "./useJsonRpc";
import { useHidRpc } from "./useHidRpc";
import { useMouseStore, useSettingsStore } from "./stores";
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
export interface AbsMouseMoveHandlerProps {
videoClientWidth: number;
videoClientHeight: number;
videoWidth: number;
videoHeight: number;
}
export default function useMouse() {
// states
const { setMousePosition, setMouseMove } = useMouseStore();
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const { mouseMode, scrollThrottling } = useSettingsStore();
// RPC hooks
const { send } = useJsonRpc();
const { reportAbsMouseEvent, reportRelMouseEvent, rpcHidReady } = useHidRpc();
// Mouse-related
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (mouseMode !== "relative") return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
const dx = calcDelta(x);
const dy = calcDelta(y);
if (rpcHidReady) {
reportRelMouseEvent(dx, dy, buttons);
} else {
// kept for backward compatibility
send("relMouseReport", { dx, dy, buttons });
}
setMouseMove({ x, y, buttons });
},
[
send,
reportRelMouseEvent,
setMouseMove,
mouseMode,
rpcHidReady,
],
);
const getRelMouseMoveHandler = useCallback(
() => (e: MouseEvent) => {
if (mouseMode !== "relative") return;
// Send mouse movement
const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons);
},
[sendRelMouseMovement, mouseMode],
);
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (mouseMode !== "absolute") return;
if (rpcHidReady) {
reportAbsMouseEvent(x, y, buttons);
} else {
// kept for backward compatibility
send("absMouseReport", { x, y, buttons });
}
// We set that for the debug info bar
setMousePosition(x, y);
},
[
send,
reportAbsMouseEvent,
setMousePosition,
mouseMode,
rpcHidReady,
],
);
const getAbsMouseMoveHandler = useCallback(
({ videoClientWidth, videoClientHeight, videoWidth, videoHeight }: AbsMouseMoveHandlerProps) => (e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
if (mouseMode !== "absolute") return;
// Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight;
// Calculate the effective video display area
let effectiveWidth = videoClientWidth;
let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
if (videoElementAspectRatio > videoStreamAspectRatio) {
// Pillarboxing: black bars on the left and right
effectiveWidth = videoClientHeight * videoStreamAspectRatio;
offsetX = (videoClientWidth - effectiveWidth) / 2;
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
// Letterboxing: black bars on the top and bottom
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
offsetY = (videoClientHeight - effectiveHeight) / 2;
}
// Clamp mouse position within the effective video boundaries
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
// Map clamped mouse position to the video stream's coordinate system
const relativeX = (clampedX - offsetX) / effectiveWidth;
const relativeY = (clampedY - offsetY) / effectiveHeight;
// Convert to HID absolute coordinate system (0-32767 range)
const x = Math.round(relativeX * 32767);
const y = Math.round(relativeY * 32767);
// Send mouse movement
const { buttons } = e;
sendAbsMouseMovement(x, y, buttons);
}, [mouseMode, sendAbsMouseMovement],
);
const getMouseWheelHandler = useCallback(
() => (e: WheelEvent) => {
if (scrollThrottling && blockWheelEvent) {
return;
}
// Determine if the wheel event is an accel scroll value
const isAccel = Math.abs(e.deltaY) >= 100;
// Calculate the accel scroll value
const accelScrollValue = e.deltaY / 100;
// Calculate the no accel scroll value
const noAccelScrollValue = Math.sign(e.deltaY);
// Get scroll value
const scrollValue = isAccel ? accelScrollValue : noAccelScrollValue;
// Apply clamping (i.e. min and max mouse wheel hardware value)
const clampedScrollValue = Math.max(-127, Math.min(127, scrollValue));
// Invert the clamped scroll value to match expected behavior
const invertedScrollValue = -clampedScrollValue;
send("wheelReport", { wheelY: invertedScrollValue });
// Apply blocking delay based of throttling settings
if (scrollThrottling && !blockWheelEvent) {
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), scrollThrottling);
}
},
[send, blockWheelEvent, scrollThrottling],
);
const resetMousePosition = useCallback(() => {
sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]);
return {
getRelMouseMoveHandler,
getAbsMouseMoveHandler,
getMouseWheelHandler,
resetMousePosition,
};
}

View File

@ -315,11 +315,30 @@ video::-webkit-media-controls {
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
}
.hg-theme-default .hg-row .down-key {
background: rgb(28, 28, 28);
@apply text-white! font-bold!;
}
.hg-theme-default .hg-row .hg-button-container,
.hg-theme-default .hg-row .hg-button:not(:last-child) {
@apply mr-[2px]! md:mr-[5px]!;
}
/* Reduce font size for selected keys when keyboard is detached */
.keyboard-detached .simple-keyboard-main.simple-keyboard {
min-width: calc(14 * 7ch);
}
.keyboard-detached .simple-keyboard.hg-theme-default div.hg-button {
text-wrap: auto;
text-align: center;
min-width: 6ch;
}
.keyboard-detached .simple-keyboard.hg-theme-default .hg-button span {
font-size: 50%;
}
/* Hide the scrollbar by setting the scrollbar color to the background color */
.xterm .xterm-viewport {
scrollbar-color: var(--color-gray-900) #002b36;

View File

@ -1,9 +1,20 @@
export interface KeyStroke { modifier: number; keys: number[]; }
export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo }
export interface KeyboardLayout { isoCode: string, name: string, chars: Record<string, KeyCombo> }
export interface KeyboardLayout {
isoCode: string;
name: string;
chars: Record<string, KeyCombo>;
modifierDisplayMap: Record<string, string>;
keyDisplayMap: Record<string, string>;
virtualKeyboard: {
main: { default: string[], shift: string[] },
control?: { default: string[], shift?: string[] },
arrows?: { default: string[] }
};
}
// to add a new layout, create a file like the above and add it to the list
// To add a new layout, create a file like the above and add it to the list
import { cs_CZ } from "@/keyboardLayouts/cs_CZ"
import { de_CH } from "@/keyboardLayouts/de_CH"
import { de_DE } from "@/keyboardLayouts/de_DE"
@ -18,15 +29,3 @@ import { nb_NO } from "@/keyboardLayouts/nb_NO"
import { sv_SE } from "@/keyboardLayouts/sv_SE"
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];
export const selectedKeyboard = (isoCode: string): KeyboardLayout => {
// fallback to original behaviour of en-US if no isoCode given
return keyboards.find(keyboard => keyboard.isoCode == isoCode)
?? keyboards.find(keyboard => keyboard.isoCode == "en-US")!;
};
export const keyboardOptions = () => {
return keyboards.map((keyboard) => {
return { label: keyboard.name, value: keyboard.isoCode }
});
}

View File

@ -1,17 +1,20 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Čeština";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyCaron = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
const keyGrave = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
const keyRing = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
const keyOverdot = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
const name = "Čeština";
const isoCode = "cs-CZ";
const keyTrema: KeyCombo = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyCaron: KeyCombo = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
const keyGrave: KeyCombo = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
const keyRing: KeyCombo = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
const keyOverdot: KeyCombo = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
const keyHook: KeyCombo = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille: KeyCombo = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
const chars = {
A: { key: "KeyA", shift: true },
@ -244,7 +247,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const cs_CZ: KeyboardLayout = {
isoCode: "cs-CZ",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,12 +1,15 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Schwiizerdütsch";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
const name = "Schwiizerdütsch";
const isoCode = "de-CH";
const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true },
@ -164,8 +167,22 @@ const chars = {
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const keyDisplayMap = {
...en_US.keyDisplayMap,
BracketLeft: "è",
"(BracketLeft)": "ü",
Semicolon: "é",
"(Semicolon)": "ö",
Quote: "à",
"(Quote)": "ä",
} as Record<string, string>;
export const de_CH: KeyboardLayout = {
isoCode: "de-CH",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,113 +1,146 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Deutsch";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const name = "Deutsch";
const isoCode = "de-DE";
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const chars = {
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave },
A: { key: "KeyA", shift: true },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
"☺": { key: "KeyA", altRight: true }, // white smiling face ☺
b: { key: "KeyB" },
B: { key: "KeyB", shift: true },
"": { key: "KeyB", altRight: true }, // single left-pointing angle quotation mark,
c: { key: "KeyC" },
C: { key: "KeyC", shift: true },
"\u202f": { key: "KeyC", altRight: true }, // narrow no-break space
d: { key: "KeyD" },
D: { key: "KeyD", shift: true },
"": { key: "KeyD", altRight: true }, // prime, mark placed above the letter
e: { key: "KeyE" },
"é": { key: "KeyE", accentKey: keyAcute },
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"€": { key: "KeyE", altRight: true },
E: { key: "KeyE", shift: true },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave},
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"é": { key: "KeyE", accentKey: keyAcute},
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
F: { key: "KeyF", shift: true },
"˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟
G: { key: "KeyG", shift: true },
g: { key: "KeyG" },
"ẞ": { key: "KeyG", altRight: true }, // capital sharp S, ẞ
h: { key: "KeyH" },
H: { key: "KeyH", shift: true },
"ˍ": { key: "KeyH", deadKey: true, altRight: true }, // modifier letter low macron, ˍ
i: { key: "KeyI" },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"˜": { key: "KeyI", deadKey: true, altRight: true }, // tilde accent, mark ˜ placed above the letter
j: { key: "KeyJ" },
J: { key: "KeyJ", shift: true },
"¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter
k: { key: "KeyK" },
K: { key: "KeyK", shift: true },
l: { key: "KeyL" },
L: { key: "KeyL", shift: true },
"ˏ": { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ
m: { key: "KeyM" },
M: { key: "KeyM", shift: true },
"µ": { key: "KeyM", altRight: true },
n: { key: "KeyN" },
N: { key: "KeyN", shift: true },
"": { key: "KeyN", altRight: true }, // en dash,
o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚
p: { key: "KeyP" },
P: { key: "KeyP", shift: true },
"ˀ": { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ
q: { key: "KeyQ" },
Q: { key: "KeyQ", shift: true },
"@": { key: "KeyQ", altRight: true },
R: { key: "KeyR", shift: true },
r: { key: "KeyR" },
"˝": { key: "KeyR", deadKey: true, altRight: true }, // double acute accent, mark ˝ placed above the letter
S: { key: "KeyS", shift: true },
s: { key: "KeyS" },
"″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter
T: { key: "KeyT", shift: true },
t: { key: "KeyT" },
"ˇ": { key: "KeyT", deadKey: true, altRight: true }, // caron/hacek accent, mark ˇ placed above the letter
u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter
v: { key: "KeyV" },
V: { key: "KeyV", shift: true },
"«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, «
w: { key: "KeyW" },
W: { key: "KeyW", shift: true },
"¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter
x: { key: "KeyX" },
X: { key: "KeyX", shift: true },
"»": { key: "KeyX", altRight: true },
// cross key between shift and y (aka OEM 102 key)
y: { key: "KeyZ" },
Y: { key: "KeyZ", shift: true },
"": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark,
z: { key: "KeyY" },
Z: { key: "KeyY", shift: true },
"¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter
"°": { key: "Backquote", shift: true },
"^": { key: "Backquote", deadKey: true },
"|": { key: "Backquote", altRight: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
"": { key: "Digit1", altRight: true }, // single quote, mark placed above the letter
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
"²": { key: "Digit2", altRight: true },
"<": { key: "Digit2", altRight: true }, // non-US < and >
3: { key: "Digit3" },
"§": { key: "Digit3", shift: true },
"³": { key: "Digit3", altRight: true },
">": { key: "Digit3", altRight: true }, // non-US < and >
4: { key: "Digit4" },
"$": { key: "Digit4", shift: true },
"—": { key: "Digit4", altRight: true }, // em dash, —
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
"¡": { key: "Digit5", altRight: true }, // inverted exclamation mark, ¡
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
"¿": { key: "Digit6", altRight: true }, // inverted question mark, ¿
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true },
@ -123,36 +156,192 @@ const chars = {
"ß": { key: "Minus" },
"?": { key: "Minus", shift: true },
"\\": { key: "Minus", altRight: true },
"´": { key: "Equal", deadKey: true },
"`": { key: "Equal", shift: true, deadKey: true },
"´": { key: "Equal", deadKey: true }, // accent acute, mark ´ placed above the letter
"`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter
"˙": { key: "Equal", control: true, altRight: true, deadKey: true }, // acute accent, mark ˙ placed above the letter
"ü": { key: "BracketLeft" },
"Ü": { key: "BracketLeft", shift: true },
Escape: { key: "BracketLeft", control: true },
"ʼ": { key: "BracketLeft", altRight: true }, // modifier letter apostrophe, ʼ
"+": { key: "BracketRight" },
"*": { key: "BracketRight", shift: true },
Control: { key: "BracketRight", control: true },
"~": { key: "BracketRight", altRight: true },
"ö": { key: "Semicolon" },
"Ö": { key: "Semicolon", shift: true },
"ˌ": { key: "Semicolon", deadkey: true, altRight: true }, // modifier letter low vertical line, ˌ
"ä": { key: "Quote" },
"Ä": { key: "Quote", shift: true },
"˗": { key: "Quote", deadKey: true, altRight: true }, // modifier letter minus sign, ˗
"#": { key: "Backslash" },
"'": { key: "Backslash", shift: true },
"": { key: "Backslash", altRight: true }, // minus sign,
",": { key: "Comma" },
";": { key: "Comma", shift: true },
"\u2011": { key: "Comma", altRight: true }, // non-breaking hyphen,
".": { key: "Period" },
":": { key: "Period", shift: true },
"·": { key: "Period", altRight: true }, // middle dot, ·
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"|": { key: "IntlBackslash", altRight: true },
"\u00ad": { key: "Slash", altRight: true }, // soft hyphen, ­
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const keyDisplayMap: Record<string, string> = {
...en_US.keyDisplayMap,
// now override the English keyDisplayMap with German specific keys
// Combination keys
CtrlAltDelete: "Strg + Alt + Entf",
CtrlAltBackspace: "Strg + Alt + ←",
// German action keys
AltLeft: "Alt",
AltRight: "AltGr",
Backspace: "Rücktaste",
"(Backspace)": "Rücktaste",
CapsLock: "Feststelltaste",
Clear: "Entf",
ControlLeft: "Strg",
ControlRight: "Strg",
Delete: "Entf",
End: "Ende",
Enter: "Eingabe",
Escape: "Esc",
Home: "Pos1",
Insert: "Einfg",
Menu: "Menü",
MetaLeft: "Meta",
MetaRight: "Meta",
PageDown: "Bild ↓",
PageUp: "Bild ↑",
ShiftLeft: "Umschalt",
ShiftRight: "Umschalt",
// German umlauts and ß
BracketLeft: "ü",
"(BracketLeft)": "Ü",
Semicolon: "ö",
"(Semicolon)": "Ö",
Quote: "ä",
"(Quote)": "Ä",
Minus: "ß",
"(Minus)": "?",
Equal: "´",
"(Equal)": "`",
Backslash: "#",
"(Backslash)": "'",
// Shifted Numbers
"(Digit2)": "\"",
"(Digit3)": "§",
"(Digit6)": "&",
"(Digit7)": "/",
"(Digit8)": "(",
"(Digit9)": ")",
"(Digit0)": "=",
// Additional German symbols
Backquote: "^",
"(Backquote)": "°",
Comma: ",",
"(Comma)": ";",
Period: ".",
"(Period)": ":",
Slash: "-",
"(Slash)": "_",
// Numpad
NumpadDecimal: "Num ,",
NumpadEnter: "Num Eingabe",
NumpadInsert: "Einfg",
NumpadDelete: "Entf",
// Modals
PrintScreen: "Druck",
ScrollLock: "Rollen",
"(Pause)": "Unterbr",
}
export const modifierDisplayMap: Record<string, string> = {
ShiftLeft: "Umschalt (links)",
ShiftRight: "Umschalt (rechts)",
ControlLeft: "Strg (links)",
ControlRight: "Strg (rechts)",
AltLeft: "Alt",
AltRight: "AltGr",
MetaLeft: "Meta (links)",
MetaRight: "Meta (rechts)",
AltGr: "AltGr",
} as Record<string, string>;
export const virtualKeyboard = {
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
]
},
control: {
default: [
"PrintScreen ScrollLock Pause",
"Insert Home PageUp",
"Delete End PageDown"
],
shift: [
"(PrintScreen) ScrollLock (Pause)",
"Insert Home PageUp",
"Delete End PageDown"
],
},
arrows: {
default: [
" ArrowUp ",
"ArrowLeft ArrowDown ArrowRight"],
},
numpad: {
numlocked: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Numpad7 Numpad8 Numpad9 NumpadAdd",
"Numpad4 Numpad5 Numpad6",
"Numpad1 Numpad2 Numpad3 NumpadEnter",
"Numpad0 NumpadDecimal",
],
default: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Home ArrowUp PageUp NumpadAdd",
"ArrowLeft Clear ArrowRight",
"End ArrowDown PageDown NumpadEnter",
"NumpadInsert NumpadDelete",
],
}
}
export const de_DE: KeyboardLayout = {
isoCode: "de-DE",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
keyDisplayMap: keyDisplayMap,
modifierDisplayMap: modifierDisplayMap,
virtualKeyboard: virtualKeyboard
};

View File

@ -1,6 +1,9 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const name = "English (UK)";
const isoCode = "en-UK";
const chars = {
A: { key: "KeyA", shift: true },
@ -107,7 +110,11 @@ const chars = {
} as Record<string, KeyCombo>
export const en_UK: KeyboardLayout = {
isoCode: "en-UK",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,8 +1,18 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "English (US)";
const isoCode = "en-US";
const chars = {
// dead keys for "international" 101 keyboards TODO
/*
const keyAcute = { key: "Quote", control: true, menu: true, mark: "´" } // acute accent
const keyCedilla = { key: ".", shift: true, alt: true, mark: "¸" } // cedilla accent
const keyComma = { key: "BracketRight", shift: true, altRight: true, mark: "," } // comma accent
const keyDiaeresis = { key: "Quote", shift: true, control: true, menu: true, mark: "¨" } // diaeresis accent
const keyDegree = { key: "Semicolon", shift: true, control: true, menu: true, mark: "°" } // degree accent
*/
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
@ -89,31 +99,213 @@ const chars = {
">": { key: "Period", shift: true },
";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true },
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
"[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true },
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
"]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true },
"»": { key: "BracketRight", altRight: true }, // double right quote sign
"\\": { key: "Backslash" },
"|": { key: "Backslash", shift: true },
"¬": { key: "Backslash", altRight: true }, // not sign
"`": { key: "Backquote" },
"~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true },
" ": { key: "Space", shift: false },
"\n": { key: "Enter", shift: false },
Enter: { key: "Enter", shift: false },
Tab: { key: "Tab", shift: false },
PrintScreen: { key: "Prt Sc", shift: false },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Escape: { key: "Escape" },
Tab: { key: "Tab" },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause", shift: false },
ScrollLock: { key: "ScrollLock" },
Pause: { key: "Pause" },
Break: { key: "Pause", shift: true },
Insert: { key: "Insert", shift: false },
Delete: { key: "Delete", shift: false },
Insert: { key: "Insert" },
Delete: { key: "Delete" },
} as Record<string, KeyCombo>
export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl",
ShiftLeft: "Left Shift",
ShiftRight: "Right Shift",
AltLeft: "Left Alt",
AltRight: "Right Alt",
MetaLeft: "Left Meta",
MetaRight: "Right Meta",
AltGr: "AltGr",
} as Record<string, string>;
export const keyDisplayMap: Record<string, string> = {
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
AltGr: "AltGr",
AltLeft: "Alt ⌥",
AltRight: "⌥ Alt",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Backspace: "Backspace",
"(Backspace)": "Backspace",
CapsLock: "Caps Lock ⇪",
Clear: "Clear",
ControlLeft: "Ctrl ⌃",
ControlRight: "⌃ Ctrl",
Delete: "Delete ⌦",
End: "End",
Enter: "Enter",
Escape: "Esc",
Home: "Home",
Insert: "Insert",
Menu: "Menu",
MetaLeft: "Meta ⌘",
MetaRight: "⌘ Meta",
PageDown: "PgDn",
PageUp: "PgUp",
ShiftLeft: "Shift ⇧",
ShiftRight: "⇧ Shift",
Space: " ",
Tab: "Tab ⇥",
// Letters
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
KeyZ: "z",
// Capital letters
"(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E",
"(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J",
"(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O",
"(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T",
"(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y",
"(KeyZ)": "Z",
// Numbers
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
// Shifted Numbers
"(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%",
"(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")",
// Symbols
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
"(BracketLeft)": "{",
BracketRight: "]",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": "\"",
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
// Function keys
F1: "F1", F2: "F2", F3: "F3", F4: "F4",
F5: "F5", F6: "F6", F7: "F7", F8: "F8",
F9: "F9", F10: "F10", F11: "F11", F12: "F12",
// Numpad
Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
NumpadEqual: "Num =", NumpadEnter: "Num Enter", NumpadInsert: "Ins",
NumpadDelete: "Del", NumLock: "Num Lock",
// Modals
PrintScreen: "Prt Sc", ScrollLock: "Scr Lk", Pause: "Pause",
"(PrintScreen)": "Sys Rq", "(Pause)": "Break",
SystemRequest: "Sys Rq", Break: "Break"
};
export const virtualKeyboard = {
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
]
},
control: {
default: [
"PrintScreen ScrollLock Pause",
"Insert Home PageUp",
"Delete End PageDown"
],
shift: [
"(PrintScreen) ScrollLock (Pause)",
"Insert Home PageUp",
"Delete End PageDown"
],
},
arrows: {
default: [
"ArrowUp",
"ArrowLeft ArrowDown ArrowRight"],
},
numpad: {
numlocked: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Numpad7 Numpad8 Numpad9 NumpadAdd",
"Numpad4 Numpad5 Numpad6",
"Numpad1 Numpad2 Numpad3 NumpadEnter",
"Numpad0 NumpadDecimal",
],
default: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Home ArrowUp PageUp NumpadAdd",
"ArrowLeft Clear ArrowRight",
"End ArrowDown PageDown NumpadEnter",
"NumpadInsert NumpadDelete",
],
}
}
export const en_US: KeyboardLayout = {
isoCode: "en-US",
name: name,
chars: chars
};
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard
};

View File

@ -1,12 +1,15 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Español";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
const name = "Español";
const isoCode = "es-ES";
const keyTrema: KeyCombo = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true },
@ -168,7 +171,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const es_ES: KeyboardLayout = {
isoCode: "es-ES",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,12 +1,15 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Belgisch Nederlands";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyAcute = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
const name = "Belgisch Nederlands";
const isoCode = "nl-BE";
const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyAcute: KeyCombo = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyGrave: KeyCombo = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyQ", shift: true },
@ -167,7 +170,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const fr_BE: KeyboardLayout = {
isoCode: "fr-BE",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -3,6 +3,7 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { de_CH } from "./de_CH"
const name = "Français de Suisse";
const isoCode = "fr-CH";
const chars = {
...de_CH.chars,
@ -14,8 +15,22 @@ const chars = {
"ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>;
const keyDisplayMap = {
...de_CH.keyDisplayMap,
"BracketLeft": "è",
"BracketLeftShift": "ü",
"Semicolon": "é",
"SemicolonShift": "ö",
"Quote": "à",
"QuoteShift": "ä",
} as Record<string, string>;
export const fr_CH: KeyboardLayout = {
isoCode: "fr-CH",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: de_CH.modifierDisplayMap,
virtualKeyboard: de_CH.virtualKeyboard
};

View File

@ -1,9 +1,12 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Français";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const name = "Français";
const isoCode = "fr-FR";
const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const chars = {
A: { key: "KeyQ", shift: true },
@ -139,7 +142,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const fr_FR: KeyboardLayout = {
isoCode: "fr-FR",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,6 +1,9 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const name = "Italiano";
const isoCode = "it-IT";
const chars = {
A: { key: "KeyA", shift: true },
@ -113,7 +116,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const it_IT: KeyboardLayout = {
isoCode: "it-IT",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,12 +1,15 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Norsk bokmål";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
const name = "Norsk bokmål";
const isoCode = "nb-NO";
const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true },
@ -167,7 +170,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const nb_NO: KeyboardLayout = {
isoCode: "nb-NO",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,12 +1,15 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
const name = "Svenska";
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
const name = "Svenska";
const isoCode = "sv-SE";
const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true },
@ -164,7 +167,11 @@ const chars = {
} as Record<string, KeyCombo>;
export const sv_SE: KeyboardLayout = {
isoCode: "sv-SE",
isoCode: isoCode,
name: name,
chars: chars
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,20 +1,39 @@
// Key codes and modifiers correspond to definitions in the
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
// [Section 10. Keyboard/Keypad Page 0x07](https://usb.org/sites/default/files/hut1_21.pdf)
// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf)
// These are all the key codes (not scan codes) that an 85/101/102 keyboard might have on it
export const keys = {
Again: 0x79,
AlternateErase: 0x9d,
AltGr: 0xe6, // aka AltRight
AltLeft: 0xe2,
AltRight: 0xe6,
Application: 0x65,
ArrowDown: 0x51,
ArrowLeft: 0x50,
ArrowRight: 0x4f,
ArrowUp: 0x52,
Attention: 0x9a,
Backquote: 0x35, // aka Grave
Backslash: 0x31,
Backspace: 0x2a,
BracketLeft: 0x2f, // aka LeftBrace
BracketRight: 0x30, // aka RightBrace
Cancel: 0x9b,
CapsLock: 0x39,
Clear: 0x9c,
ClearAgain: 0xa2,
Comma: 0x36,
Compose: 0x65,
ContextMenu: 0,
Compose: 0xe3,
ContextMenu: 0x65,
ControlLeft: 0xe0,
ControlRight: 0xe4,
Copy: 0x7c,
CrSel: 0xa3,
CurrencySubunit: 0xb5,
CurrencyUnit: 0xb4,
Cut: 0x7b,
DecimalSeparator: 0xb3,
Delete: 0x4c,
Digit0: 0x27,
Digit1: 0x1e,
@ -30,6 +49,8 @@ export const keys = {
Enter: 0x28,
Equal: 0x2e,
Escape: 0x29,
Execute: 0x74,
ExSel: 0xa4,
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
@ -42,6 +63,7 @@ export const keys = {
F10: 0x43,
F11: 0x44,
F12: 0x45,
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
@ -53,9 +75,15 @@ export const keys = {
F22: 0x71,
F23: 0x72,
F24: 0x73,
Home: 0x4a,
Find: 0x7e,
Grave: 0x35,
HashTilde: 0x32, // non-US # and ~
Help: 0x75,
Home: 0x4a,
Insert: 0x49,
International7: 0x8d,
International8: 0x8e,
International9: 0x8f,
IntlBackslash: 0x64, // non-US \ and |
KeyA: 0x04,
KeyB: 0x05,
@ -83,11 +111,33 @@ export const keys = {
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
KeypadExclamation: 0xcf,
KeyRO: 0x87,
KatakanaHiragana: 0x88,
Yen: 0x89,
Henkan: 0x8a,
Muhenkan: 0x8b,
KPJPComma: 0x8c,
Hangeul: 0x90,
Hanja: 0x91,
Katakana: 0x92,
Hiragana: 0x93,
ZenkakuHankaku:0x94,
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
Lang6: 0x95,
Lang7: 0x96,
Lang8: 0x97,
Lang9: 0x98,
Menu: 0x76,
MetaLeft: 0xe3,
MetaRight: 0xe7,
Minus: 0x2d,
None: 0x00,
Mute: 0x7f,
NumLock: 0x53, // and Clear
Numpad0: 0x62, // and Insert
Numpad00: 0xb0,
Numpad000: 0xb1,
Numpad1: 0x59, // and End
Numpad2: 0x5a, // and Down Arrow
Numpad3: 0x5b, // and Page Down
@ -98,30 +148,111 @@ export const keys = {
Numpad8: 0x60, // and Up Arrow
Numpad9: 0x61, // and Page Up
NumpadAdd: 0x57,
NumpadAnd: 0xc7,
NumpadAt: 0xce,
NumpadBackspace: 0xbb,
NumpadBinary: 0xda,
NumpadCircumflex: 0xc3,
NumpadClear: 0xd8,
NumpadClearEntry: 0xd9,
NumpadColon: 0xcb,
NumpadComma: 0x85,
NumpadDecimal: 0x63,
NumpadDecimal: 0x63, // and Delete
NumpadDecimalBase: 0xdc,
NumpadDelete: 0x63,
NumpadDivide: 0x54,
NumpadDownArrow: 0x5a,
NumpadEnd: 0x59,
NumpadEnter: 0x58,
NumpadEqual: 0x67,
NumpadExclamation: 0xcf,
NumpadGreaterThan: 0xc6,
NumpadHexadecimal: 0xdd,
NumpadHome: 0x5f,
NumpadKeyA: 0xbc,
NumpadKeyB: 0xbd,
NumpadKeyC: 0xbe,
NumpadKeyD: 0xbf,
NumpadKeyE: 0xc0,
NumpadKeyF: 0xc1,
NumpadLeftArrow: 0x5c,
NumpadLeftBrace: 0xb8,
NumpadLeftParen: 0xb6,
NumpadLessThan: 0xc5,
NumpadLogicalAnd: 0xc8,
NumpadLogicalOr: 0xca,
NumpadMemoryAdd: 0xd3,
NumpadMemoryClear: 0xd2,
NumpadMemoryDivide: 0xd6,
NumpadMemoryMultiply: 0xd5,
NumpadMemoryRecall: 0xd1,
NumpadMemoryStore: 0xd0,
NumpadMemorySubtract: 0xd4,
NumpadMultiply: 0x55,
NumpadOctal: 0xdb,
NumpadOctathorpe: 0xcc,
NumpadOr: 0xc9,
NumpadPageDown: 0x5b,
NumpadPageUp: 0x61,
NumpadPercent: 0xc4,
NumpadPlusMinus: 0xd7,
NumpadRightArrow: 0x5e,
NumpadRightBrace: 0xb9,
NumpadRightParen: 0xb7,
NumpadSpace: 0xcd,
NumpadSubtract: 0x56,
NumpadTab: 0xba,
NumpadUpArrow: 0x60,
NumpadXOR: 0xc2,
Octothorpe: 0x32, // non-US # and ~
Operation: 0xa1,
Out: 0xa0,
PageDown: 0x4e,
PageUp: 0x4b,
Period: 0x37,
PrintScreen: 0x46,
Paste: 0x7d,
Pause: 0x48,
Period: 0x37, // aka Dot
Power: 0x66,
PrintScreen: 0x46,
Prior: 0x9d,
Quote: 0x34, // aka Single Quote or Apostrophe
Return: 0x9e,
ScrollLock: 0x47,
Select: 0x77,
Semicolon: 0x33,
Separator: 0x9f,
ShiftLeft: 0xe1,
ShiftRight: 0xe5,
Slash: 0x38,
Space: 0x2c,
SystemRequest: 0x9a,
Stop: 0x78,
SystemRequest: 0x9a, // aka Attention
Tab: 0x2b,
ThousandsSeparator: 0xb2,
Tilde: 0x35,
Undo: 0x7a,
VolumeDown: 0x81,
VolumeUp: 0x80,
} as Record<string, number>;
export const deadKeys = {
Acute: 0x00b4,
Breve: 0x02d8,
Caron: 0x02c7,
Cedilla: 0x00b8,
Circumflex: 0x005e, // or 0x02c6?
Comma: 0x002c,
Dot: 0x00b7,
DoubleAcute: 0x02dd,
Grave: 0x0060,
Kreis: 0x00b0,
Ogonek: 0x02db,
Ring: 0x02da,
Slash: 0x02f8,
Tilde: 0x007e,
Umlaut: 0x00a8,
} as Record<string, number>
export const modifiers = {
ControlLeft: 0x01,
ControlRight: 0x10,
@ -131,113 +262,28 @@ export const modifiers = {
AltRight: 0x40,
MetaLeft: 0x08,
MetaRight: 0x80,
AltGr: 0x40,
} as Record<string, number>;
export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl",
ShiftLeft: "Left Shift",
ShiftRight: "Right Shift",
AltLeft: "Left Alt",
AltRight: "Right Alt",
MetaLeft: "Left Meta",
MetaRight: "Right Meta",
} as Record<string, string>;
export const hidKeyToModifierMask = {
0xe0: modifiers.ControlLeft,
0xe1: modifiers.ShiftLeft,
0xe2: modifiers.AltLeft,
0xe3: modifiers.MetaLeft,
0xe4: modifiers.ControlRight,
0xe5: modifiers.ShiftRight,
0xe6: modifiers.AltRight, // can also be AltGr
0xe7: modifiers.MetaRight,
} as Record<number, number>;
export const keyDisplayMap: Record<string, string> = {
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
Space: " ",
Insert: "insert",
Home: "home",
PageUp: "page up",
Delete: "delete",
End: "end",
PageDown: "page down",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
// Letters
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
KeyZ: "z",
export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"];
// Capital letters
"(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E",
"(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J",
"(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O",
"(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T",
"(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y",
"(KeyZ)": "Z",
// Numbers
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
// Shifted Numbers
"(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%",
"(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")",
// Symbols
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
"(BracketLeft)": "{",
BracketRight: "]",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": "\"",
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
// Function keys
F1: "F1", F2: "F2", F3: "F3", F4: "F4",
F5: "F5", F6: "F6", F7: "F7", F8: "F8",
F9: "F9", F10: "F10", F11: "F11", F12: "F12",
// Numpad
Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
NumpadEqual: "Num =", NumpadEnter: "Num Enter",
NumLock: "Num Lock",
// Modals
PrintScreen: "prt sc", ScrollLock: "scr lk", Pause: "pause",
"(PrintScreen)": "sys rq", "(Pause)": "break",
SystemRequest: "sys rq", Break: "break"
};
export function decodeModifiers(modifier: number) {
return {
isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0,
isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0,
isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0,
isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0,
isAltGrActive: (modifier & modifiers.AltGr) !== 0,
};
}

View File

@ -1,3 +1,4 @@
import { lazy } from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import {
@ -6,49 +7,48 @@ import {
redirect,
RouterProvider,
useRouteError,
} from "react-router-dom";
} from "react-router";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api";
import Root from "@/root";
import Card from "@components/Card";
import EmptyCard from "@components/EmptyCard";
import NotFoundPage from "@components/NotFoundPage";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import WelcomeRoute, { DeviceStatus } from "@routes/welcome-local";
import LoginLocalRoute from "@routes/login-local";
import WelcomeLocalModeRoute from "@routes/welcome-local.mode";
import WelcomeLocalPasswordRoute from "@routes/welcome-local.password";
import AdoptRoute from "@routes/adopt";
import SetupRoute from "@routes/devices.$id.setup";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import DeviceIdRename from "@routes/devices.$id.rename";
import AdoptRoute from "@routes/adopt";
import SignupRoute from "@routes/signup";
import LoginRoute from "@routes/login";
import SetupRoute from "@routes/devices.$id.setup";
import DevicesRoute from "@routes/devices";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import Card from "@components/Card";
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Root from "./root";
import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsMouseRoute from "./routes/devices.$id.settings.mouse";
import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard";
import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
import SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralRebootRoute from "./routes/devices.$id.settings.general.reboot";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SettingsNetworkRoute from "./routes/devices.$id.settings.network";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
import SettingsIndexRoute from "@routes/devices.$id.settings._index";
import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index";
import Notifications from "@/notifications";
const SignupRoute = lazy(() => import("@routes/signup"));
const LoginRoute = lazy(() => import("@routes/login"));
const DevicesAlreadyAdopted = lazy(() => import("@routes/devices.already-adopted"));
const OtherSessionRoute = lazy(() => import("@routes/devices.$id.other-session"));
const MountRoute = lazy(() => import("./routes/devices.$id.mount"));
const SettingsRoute = lazy(() => import("@routes/devices.$id.settings"));
const SettingsMouseRoute = lazy(() => import("@routes/devices.$id.settings.mouse"));
const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.keyboard"));
const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced"));
const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware"));
const SettingsVideoRoute = lazy(() => import("@routes/devices.$id.settings.video"));
const SettingsAppearanceRoute = lazy(() => import("@routes/devices.$id.settings.appearance"));
const SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index"));
const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot"));
const SettingsGeneralUpdateRoute = lazy(() => import("@routes/devices.$id.settings.general.update"));
const SettingsNetworkRoute = lazy(() => import("@routes/devices.$id.settings.network"));
const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.settings.access.local-auth"));
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
@ -128,7 +128,7 @@ if (isOnDevice) {
},
{
path: "settings",
element: <SettingsRoute.default />,
element: <SettingsRoute />,
children: [
{
index: true,
@ -139,7 +139,7 @@ if (isOnDevice) {
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
element: <SettingsGeneralIndexRoute />,
},
{
path: "reboot",
@ -265,7 +265,7 @@ if (isOnDevice) {
},
{
path: "settings",
element: <SettingsRoute.default />,
element: <SettingsRoute />,
children: [
{
index: true,
@ -276,7 +276,7 @@ if (isOnDevice) {
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
element: <SettingsGeneralIndexRoute />,
},
{
path: "update",

View File

@ -1,4 +1,4 @@
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
function Root() {
return <Outlet />;

View File

@ -1,8 +1,8 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { redirect } from "react-router";
import type { LoaderFunction, LoaderFunctionArgs } from "react-router";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import api from "@/api";
export interface CloudState {
connected: boolean;
@ -10,7 +10,7 @@ export interface CloudState {
appUrl: string;
}
const loader = async ({ request }: LoaderFunctionArgs) => {
const loader: LoaderFunction = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
@ -37,7 +37,7 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
};
export default function AdoptRoute() {
return <></>;
return (<></>);
}
AdoptRoute.loader = loader;

View File

@ -1,11 +1,5 @@
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
redirect,
useActionData,
useLoaderData,
} from "react-router-dom";
import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button";
@ -22,7 +16,7 @@ interface LoaderData {
user: User;
}
const action = async ({ request }: ActionFunctionArgs) => {
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData());
try {
@ -34,17 +28,17 @@ const action = async ({ request }: ActionFunctionArgs) => {
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
return { message: "There was an error deregistering your device. Please try again." };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
return { message: "There was an error deregistering your device. Please try again." };
}
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;

View File

@ -1,15 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LuGlobe,
LuLink,
LuRadioReceiver,
LuHardDrive,
LuCheck,
LuUpload,
} from "react-icons/lu";
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
@ -50,7 +48,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const {
modalView,
setModalView,
setLocalFile,
setRemoteVirtualMediaState,
errorMessage,
setErrorMessage,
@ -60,7 +57,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false);
function clearMountMediaState() {
setLocalFile(null);
setRemoteVirtualMediaState(null);
}
@ -89,7 +85,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
console.log(`Mounting ${url} as ${mode}`);
setMountInProgress(true);
send("mountWithHTTP", { url, mode }, async (resp: JsonRpcResponse) => {
send("mountWithHTTP", { url, mode }, (resp: JsonRpcResponse) => {
if ("error" in resp) triggerError(resp.error.message);
clearMountMediaState();
@ -108,7 +104,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
console.log(`Mounting ${fileName} as ${mode}`);
setMountInProgress(true);
send("mountWithStorage", { filename: fileName, mode }, async (resp: JsonRpcResponse) => {
send("mountWithStorage", { filename: fileName, mode }, (resp: JsonRpcResponse) => {
if ("error" in resp) triggerError(resp.error.message);
clearMountMediaState();
@ -131,35 +127,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState();
}
function handleBrowserMount(file: File, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${file.name} as ${mode}`);
setMountInProgress(true);
send(
"mountWithWebRTC",
{ filename: file.name, size: file.size, mode },
async resp => {
if ("error" in resp) triggerError(resp.error.message);
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
// We need to keep the local file in the store so that the browser can
// continue to stream the file to the device
setLocalFile(file);
navigate("..");
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
setMountInProgress(false);
});
},
);
}
const [selectedMode, setSelectedMode] = useState<"browser" | "url" | "device">("url");
const [selectedMode, setSelectedMode] = useState<"url" | "device">("url");
return (
<AutoHeight>
<div
@ -167,7 +135,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
"max-w-4xl": modalView === "mode",
"max-w-2xl": modalView === "device",
"max-w-xl":
modalView === "browser" ||
modalView === "url" ||
modalView === "upload" ||
modalView === "error",
@ -194,19 +161,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
/>
)}
{modalView === "browser" && (
<BrowserFileView
mountInProgress={mountInProgress}
onMountFile={(file, mode) => {
handleBrowserMount(file, mode);
}}
onBack={() => {
setMountInProgress(false);
setModalView("mode");
}}
/>
)}
{modalView === "url" && (
<UrlView
mountInProgress={mountInProgress}
@ -275,8 +229,8 @@ function ModeSelectionView({
setSelectedMode,
}: {
onClose: () => void;
selectedMode: "browser" | "url" | "device";
setSelectedMode: (mode: "browser" | "url" | "device") => void;
selectedMode: "url" | "device";
setSelectedMode: (mode: "url" | "device") => void;
}) {
const { setModalView } = useMountMediaStore();
@ -292,14 +246,6 @@ function ModeSelectionView({
</div>
<div className="grid gap-4 md:grid-cols-3">
{[
{
label: "Browser Mount",
value: "browser",
description: "Stream files directly from your browser",
icon: LuGlobe,
tag: "Coming Soon",
disabled: true,
},
{
label: "URL Mount",
value: "url",
@ -338,7 +284,7 @@ function ModeSelectionView({
<div
className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
disabled ? null : setSelectedMode(mode as "url" | "device")
}
>
<div>
@ -394,119 +340,6 @@ function ModeSelectionView({
);
}
function BrowserFileView({
onMountFile,
onBack,
mountInProgress,
}: {
onBack: () => void;
onMountFile: (file: File, mode: RemoteVirtualMediaState["mode"]) => void;
mountInProgress: boolean;
}) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
setSelectedFile(file);
if (file?.name.endsWith(".iso")) {
setUsbMode("CDROM");
} else if (file?.name.endsWith(".img")) {
setUsbMode("Disk");
}
};
const handleMount = () => {
if (selectedFile) {
console.log(`Mounting ${selectedFile.name} as ${setUsbMode}`);
onMountFile(selectedFile, usbMode);
}
};
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from Browser"
description="Select an image file to mount"
/>
<div className="space-y-2">
<div
onClick={() => document.getElementById("file-upload")?.click()}
className="block cursor-pointer select-none"
>
<div
className="group animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
>
<Card className="transition-all duration-300 outline-dashed">
<div className="w-full px-4 py-12">
<div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? (
<>
<div className="space-y-1">
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm leading-none font-semibold">
{formatters.truncateMiddle(selectedFile.name, 40)}
</h3>
<p className="text-xs leading-none text-slate-700">
{formatters.bytes(selectedFile.size)}
</p>
</div>
</>
) : (
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm leading-none font-semibold">
Click to select a file
</h3>
<p className="text-xs leading-none text-slate-700">
Supported formats: ISO, IMG
</p>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
<input
id="file-upload"
type="file"
onChange={handleFileChange}
className="hidden"
accept=".iso, .img"
/>
</div>
<div
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<Fieldset disabled={!selectedFile}>
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex space-x-2">
<Button size="MD" theme="blank" text="Back" onClick={onBack} />
<Button
size="MD"
theme="primary"
text="Mount File"
onClick={handleMount}
disabled={!selectedFile || mountInProgress}
loading={mountInProgress}
/>
</div>
</div>
</div>
);
}
function UrlView({
onBack,
onMount,

View File

@ -1,4 +1,4 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { useNavigate, useOutletContext } from "react-router";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";

View File

@ -1,11 +1,5 @@
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
redirect,
useActionData,
useLoaderData,
} from "react-router-dom";
import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button";
@ -25,7 +19,7 @@ interface LoaderData {
user: User;
}
const action = async ({ params, request }: ActionFunctionArgs) => {
const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) => {
const { id } = params;
const { name } = Object.fromEntries(await request.formData());
@ -48,7 +42,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;

View File

@ -1,7 +1,14 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { redirect } from "react-router";
import type { LoaderFunction, LoaderFunctionArgs } from "react-router";
import { getDeviceUiPath } from "../hooks/useAppNavigation";
export function loader({ params }: LoaderFunctionArgs) {
const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
return redirect(getDeviceUiPath("/settings/general", params.id));
}
export default function SettingIndexRoute() {
return (<></>);
}
SettingIndexRoute.loader = loader;

View File

@ -1,4 +1,5 @@
import { useLoaderData, useNavigate } from "react-router-dom";
import { useLoaderData, useNavigate } from "react-router";
import type { LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useCallback, useEffect, useState } from "react";
@ -26,7 +27,7 @@ export interface TLSState {
privateKey?: string;
}
const loader = async () => {
const loader: LoaderFunction = async () => {
if (isOnDevice) {
const status = await api
.GET(`${DEVICE_API}/device`)
@ -87,7 +88,7 @@ export default function SettingsAccessIndexRoute() {
});
}, [send]);
const deregisterDevice = async () => {
const deregisterDevice = () => {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
@ -166,9 +167,7 @@ export default function SettingsAccessIndexRoute() {
notifications.success("TLS settings updated successfully");
});
},
[send],
);
}, [send]);
// Handle TLS mode change
const handleTlsModeChange = (value: string) => {
@ -198,7 +197,7 @@ export default function SettingsAccessIndexRoute() {
getCloudState();
getTLSState();
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
send("getDeviceID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router-dom";
import { useLocation, useRevalidator } from "react-router";
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField";

View File

@ -18,7 +18,7 @@ export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const { setDeveloperMode } = useSettingsStore();
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);

Some files were not shown because too many files have changed in this diff Show More