diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59053daf..8ba5444a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index fa1fe22e..6eb2978d 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -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: diff --git a/.github/workflows/ui-lint.yml b/.github/workflows/ui-lint.yml index ad002fc9..32374d33 100644 --- a/.github/workflows/ui-lint.yml +++ b/.github/workflows/ui-lint.yml @@ -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: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d95db770..c7984ceb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -301,13 +301,14 @@ export JETKVM_PROXY_URL="ws://" ### Performance Profiling -```bash -# Enable profiling -go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go +1. Enable `Developer Mode` on your JetKVM device +2. Add a password on the `Access` tab +```bash # Access profiling -curl http://:6060/debug/pprof/ +curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/ ``` + ### Advanced Environment Variables ```bash diff --git a/Makefile b/Makefile index 0da630af..586b61cb 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) -BUILDDATE ?= $(shell date -u +%FT%T%z) -BUILDTS ?= $(shell date -u +%s) -REVISION ?= $(shell git rev-parse HEAD) -VERSION_DEV ?= 0.4.7-dev$(shell date +%Y%m%d%H%M) -VERSION ?= 0.4.6 +BRANCH := $(shell git rev-parse --abbrev-ref HEAD) +BUILDDATE := $(shell date -u +%FT%T%z) +BUILDTS := $(shell date -u +%s) +REVISION := $(shell git rev-parse HEAD) +VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.4.8 PROMETHEUS_TAG := github.com/prometheus/common/version KVM_PKG_NAME := github.com/jetkvm/kvm @@ -62,10 +62,25 @@ build_dev_test: build_test2json build_gotestsum tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests . frontend: - cd ui && npm ci && npm run build:device + cd ui && npm ci && npm run build:device && \ + find ../static/ \ + -type f \ + \( -name '*.js' \ + -o -name '*.css' \ + -o -name '*.html' \ + -o -name '*.ico' \ + -o -name '*.png' \ + -o -name '*.jpg' \ + -o -name '*.jpeg' \ + -o -name '*.gif' \ + -o -name '*.svg' \ + -o -name '*.webp' \ + -o -name '*.woff2' \ + \) \ + -exec sh -c 'gzip -9 -kfv {}' \; dev_release: frontend build_dev - @echo "Uploading release..." + @echo "Uploading release... $(VERSION_DEV)" @shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256 rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256 diff --git a/cloud.go b/cloud.go index cec749e4..fb138508 100644 --- a/cloud.go +++ b/cloud.go @@ -475,6 +475,10 @@ func handleSessionRequest( cloudLogger.Info().Interface("session", session).Msg("new session accepted") cloudLogger.Trace().Interface("session", session).Msg("new session accepted") + + // Cancel any ongoing keyboard macro when session changes + cancelKeyboardMacro() + currentSession = session _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) return nil diff --git a/config.go b/config.go index 1fa56a75..680999a3 100644 --- a/config.go +++ b/config.go @@ -118,6 +118,7 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes + JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ InactivityLimitSeconds: 60, @@ -205,6 +206,15 @@ func LoadConfig() { loadedConfig.NetworkConfig = defaultConfig.NetworkConfig } + if loadedConfig.JigglerConfig == nil { + loadedConfig.JigglerConfig = defaultConfig.JigglerConfig + } + + // fixup old keyboard layout value + if loadedConfig.KeyboardLayout == "en_US" { + loadedConfig.KeyboardLayout = "en-US" + } + config = &loadedConfig logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) @@ -221,6 +231,11 @@ func SaveConfig() error { logger.Trace().Str("path", configPath).Msg("Saving config") + // fixup old keyboard layout value + if config.KeyboardLayout == "en_US" { + config.KeyboardLayout = "en-US" + } + file, err := os.Create(configPath) if err != nil { return fmt.Errorf("failed to create config file: %w", err) @@ -233,6 +248,11 @@ func SaveConfig() error { return fmt.Errorf("failed to encode config: %w", err) } + if err := file.Sync(); err != nil { + return fmt.Errorf("failed to wite config: %w", err) + } + + logger.Info().Str("path", configPath).Msg("config saved") return nil } diff --git a/display.go b/display.go index aab19fbc..16a913b7 100644 --- a/display.go +++ b/display.go @@ -1,6 +1,7 @@ package kvm import ( + "context" "errors" "fmt" "os" @@ -63,11 +64,11 @@ func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // no } func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "duration": duration}) } func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "duration": duration}) } func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { @@ -110,12 +111,6 @@ func clearDisplayState() { currentScreen = "ui_Boot_Screen" } -var ( - cloudBlinkLock sync.Mutex = sync.Mutex{} - cloudBlinkStopped bool - cloudBlinkTicker *time.Ticker -) - func updateDisplay() { updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) if usbState == "configured" { @@ -152,48 +147,81 @@ func updateDisplay() { stopCloudBlink() case CloudConnectionStateConnecting: _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") - startCloudBlink() + restartCloudBlink() case CloudConnectionStateConnected: _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") stopCloudBlink() } } -func startCloudBlink() { - if cloudBlinkTicker == nil { - cloudBlinkTicker = time.NewTicker(2 * time.Second) - } else { - // do nothing if the blink isn't stopped - if cloudBlinkStopped { - cloudBlinkLock.Lock() - defer cloudBlinkLock.Unlock() +const ( + cloudBlinkInterval = 2 * time.Second + cloudBlinkDuration = 1 * time.Second +) - cloudBlinkStopped = false - cloudBlinkTicker.Reset(2 * time.Second) +var ( + cloudBlinkTicker *time.Ticker + cloudBlinkCancel context.CancelFunc + cloudBlinkLock = sync.Mutex{} +) + +func doCloudBlink(ctx context.Context) { + for range cloudBlinkTicker.C { + if cloudConnectionState != CloudConnectionStateConnecting { + continue + } + + _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds())) + + select { + case <-ctx.Done(): + return + case <-time.After(cloudBlinkDuration): + } + + _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds())) + + select { + case <-ctx.Done(): + return + case <-time.After(cloudBlinkDuration): } } +} - go func() { - for range cloudBlinkTicker.C { - if cloudConnectionState != CloudConnectionStateConnecting { - continue - } - _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000) - time.Sleep(1000 * time.Millisecond) - _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) - time.Sleep(1000 * time.Millisecond) - } - }() +func restartCloudBlink() { + stopCloudBlink() + startCloudBlink() +} + +func startCloudBlink() { + cloudBlinkLock.Lock() + defer cloudBlinkLock.Unlock() + + if cloudBlinkTicker == nil { + cloudBlinkTicker = time.NewTicker(cloudBlinkInterval) + } else { + cloudBlinkTicker.Reset(cloudBlinkInterval) + } + + ctx, cancel := context.WithCancel(context.Background()) + cloudBlinkCancel = cancel + + go doCloudBlink(ctx) } func stopCloudBlink() { + cloudBlinkLock.Lock() + defer cloudBlinkLock.Unlock() + + if cloudBlinkCancel != nil { + cloudBlinkCancel() + cloudBlinkCancel = nil + } + if cloudBlinkTicker != nil { cloudBlinkTicker.Stop() } - - cloudBlinkLock.Lock() - defer cloudBlinkLock.Unlock() - cloudBlinkStopped = true } var ( diff --git a/go.mod b/go.mod index 3e41071e..d07ba239 100644 --- a/go.mod +++ b/go.mod @@ -6,32 +6,33 @@ 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/flatbuffers v25.2.10+incompatible 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/xid v1.6.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 +52,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,29 +65,31 @@ 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 github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/vearutop/statigz v1.5.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/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 ) diff --git a/go.sum b/go.sum index 6b75ad17..57576a3a 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -53,17 +53,19 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 +94,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 +107,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,39 +121,40 @@ 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= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -167,12 +168,14 @@ 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= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk= +github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -185,10 +188,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 +199,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= diff --git a/hidrpc.go b/hidrpc.go new file mode 100644 index 00000000..ebe03daa --- /dev/null +++ b/hidrpc.go @@ -0,0 +1,259 @@ +package kvm + +import ( + "errors" + "fmt" + "io" + "time" + + "github.com/jetkvm/kvm/internal/hidrpc" + "github.com/jetkvm/kvm/internal/usbgadget" + "github.com/rs/zerolog" +) + +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: + rpcErr = handleHidRPCKeyboardInput(message) + case hidrpc.TypeKeyboardMacroReport: + keyboardMacroReport, err := message.KeyboardMacroReport() + if err != nil { + logger.Warn().Err(err).Msg("failed to get keyboard macro report") + return + } + rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps) + case hidrpc.TypeCancelKeyboardMacroReport: + rpcCancelKeyboardMacro() + return + case hidrpc.TypeKeypressKeepAliveReport: + rpcErr = handleHidRPCKeypressKeepAlive(session) + 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(msg hidQueueMessage, session *Session) { + data := msg.Data + + scopedLogger := hidRPCLogger.With(). + Str("channel", msg.channel). + 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 + } + + if scopedLogger.GetLevel() <= zerolog.DebugLevel { + 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") + } +} + +// Tunables +// Keep in mind +// macOS default: 15 * 15 = 225ms https://discussions.apple.com/thread/1316947?sortBy=rank +// Linux default: 250ms https://man.archlinux.org/man/kbdrate.8.en +// Windows default: 1s `HKEY_CURRENT_USER\Control Panel\Accessibility\Keyboard Response\AutoRepeatDelay` + +const expectedRate = 50 * time.Millisecond // expected keepalive interval +const maxLateness = 50 * time.Millisecond // max jitter we'll tolerate OR jitter budget +const baseExtension = expectedRate + maxLateness // 100ms extension on perfect tick + +const maxStaleness = 225 * time.Millisecond // discard ancient packets outright + +func handleHidRPCKeypressKeepAlive(session *Session) error { + session.keepAliveJitterLock.Lock() + defer session.keepAliveJitterLock.Unlock() + + now := time.Now() + + // 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold + // (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright. + // This prevents “zombie” keepalives from reviving a key that should already be released. + if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness { + return nil + } + + validTick := true + timerExtension := baseExtension + + if !session.lastKeepAliveArrivalTime.IsZero() { + timeSinceLastTick := now.Sub(session.lastKeepAliveArrivalTime) + lateness := timeSinceLastTick - expectedRate + + if lateness > 0 { + if lateness <= maxLateness { + // --- Small lateness (within jitterBudget) --- + // This is normal jitter (e.g., Wi-Fi contention). + // We still accept the tick, but *reduce the extension* + // so that the total hold time stays aligned with REAL client side intent. + timerExtension -= lateness + } else { + // --- Large lateness (beyond jitterBudget) --- + // This is likely a retransmit stall or ordering delay. + // We reject the tick entirely and DO NOT extend, + // so the auto-release still fires on time. + validTick = false + } + } + } + + if !validTick { + return nil + } + // Only valid ticks update our state and extend the timer. + session.lastKeepAliveArrivalTime = now + session.lastTimerResetTime = now + if gadget != nil { + gadget.DelayAutoReleaseWithDuration(timerExtension) + } + + // On a miss: do not advance any state — keeps baseline stable. + return nil +} + +func handleHidRPCKeyboardInput(message hidrpc.Message) 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 err + } + return rpcKeypressReport(keypressReport.Key, keypressReport.Press) + case hidrpc.TypeKeyboardReport: + keyboardReport, err := message.KeyboardReport() + if err != nil { + logger.Warn().Err(err).Msg("failed to get keyboard report") + return err + } + return rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys) + } + + return 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(). + Bool("hidRPCAvailable", session.hidRPCAvailable). + Bool("HidChannel", session.HidChannel != nil). + 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() + case hidrpc.KeyboardMacroState: + message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).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 { + if errors.Is(err, io.ErrClosedPipe) { + logger.Debug().Err(err).Msg("HID RPC channel closed, skipping reportHidRPC") + return + } + logger.Warn().Err(err).Msg("failed to send HID RPC message") + } +} + +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 { + usbLogger.Debug().Interface("state", state).Msg("reporting keys down state") + writeJSONRPCEvent("keysDownState", state, s) + } + usbLogger.Debug().Interface("state", state).Msg("reporting keys down state, calling reportHidRPC") + reportHidRPC(state, s) +} + +func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroState) { + if !s.hidRPCAvailable { + writeJSONRPCEvent("keyboardMacroState", state, s) + } + reportHidRPC(state, s) +} diff --git a/internal/hidrpc/hidrpc.go b/internal/hidrpc/hidrpc.go new file mode 100644 index 00000000..7313e3b5 --- /dev/null +++ b/internal/hidrpc/hidrpc.go @@ -0,0 +1,123 @@ +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 + TypeKeypressKeepAliveReport MessageType = 0x09 + TypeMouseReport MessageType = 0x06 + TypeKeyboardMacroReport MessageType = 0x07 + TypeCancelKeyboardMacroReport MessageType = 0x08 + TypeKeyboardLedState MessageType = 0x32 + TypeKeydownState MessageType = 0x33 + TypeKeyboardMacroState MessageType = 0x34 +) + +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, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState: + return 1 + case TypePointerReport, TypeMouseReport, TypeWheelReport: + return 2 + // we don't want to block the queue for this message + case TypeCancelKeyboardMacroReport: + return 3 + 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, + } +} + +// NewKeyboardMacroStateMessage creates a new keyboard macro state message. +func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message { + data := make([]byte, 2) + if state { + data[0] = 1 + } + if isPaste { + data[1] = 1 + } + + return &Message{ + t: TypeKeyboardMacroState, + d: data, + } +} diff --git a/internal/hidrpc/message.go b/internal/hidrpc/message.go new file mode 100644 index 00000000..3f3506f7 --- /dev/null +++ b/internal/hidrpc/message.go @@ -0,0 +1,207 @@ +package hidrpc + +import ( + "encoding/binary" + "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]) + case TypeKeypressKeepAliveReport: + return "KeypressKeepAliveReport" + case TypeKeyboardMacroReport: + if len(m.d) < 5 { + return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d) + } + return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5])) + default: + 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 +} + +// Macro .. +type KeyboardMacroStep struct { + Modifier byte // 1 byte + Keys []byte // 6 bytes: hidKeyBufferSize + Delay uint16 // 2 bytes +} +type KeyboardMacroReport struct { + IsPaste bool + StepCount uint32 + Steps []KeyboardMacroStep +} + +// HidKeyBufferSize is the size of the keys buffer in the keyboard report. +const HidKeyBufferSize = 6 + +// KeyboardMacroReport returns the keyboard macro report from the message. +func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) { + if m.t != TypeKeyboardMacroReport { + return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t) + } + + isPaste := m.d[0] == uint8(1) + stepCount := binary.BigEndian.Uint32(m.d[1:5]) + + // check total length + expectedLength := int(stepCount)*9 + 5 + if len(m.d) != expectedLength { + return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength) + } + + steps := make([]KeyboardMacroStep, 0, int(stepCount)) + offset := 5 + for i := 0; i < int(stepCount); i++ { + steps = append(steps, KeyboardMacroStep{ + Modifier: m.d[offset], + Keys: m.d[offset+1 : offset+7], + Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]), + }) + + offset += 1 + HidKeyBufferSize + 2 + } + + return KeyboardMacroReport{ + IsPaste: isPaste, + Steps: steps, + StepCount: stepCount, + }, 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 +} + +type KeyboardMacroState struct { + State bool + IsPaste bool +} + +// KeyboardMacroState returns the keyboard macro state report from the message. +func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) { + if m.t != TypeKeyboardMacroState { + return KeyboardMacroState{}, fmt.Errorf("invalid message type: %d", m.t) + } + + return KeyboardMacroState{ + State: m.d[0] == uint8(1), + IsPaste: m.d[1] == uint8(1), + }, nil +} diff --git a/internal/network/config.go b/internal/network/config.go index 8a28d515..da99496f 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -56,13 +56,12 @@ type NetworkConfig struct { } func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { - mode := c.MDNSMode.String listenOptions := &mdns.MDNSListenOptions{ - IPv4: true, - IPv6: true, + IPv4: c.IPv4Mode.String != "disabled", + IPv6: c.IPv6Mode.String != "disabled", } - switch mode { + switch c.MDNSMode.String { case "ipv4_only": listenOptions.IPv6 = false case "ipv6_only": diff --git a/internal/network/netif.go b/internal/network/netif.go index 5a8dab6c..44bcaa4b 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -48,7 +48,7 @@ type NetworkInterfaceOptions struct { DefaultHostname string OnStateChange func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState) - OnDhcpLeaseChange func(lease *udhcpc.Lease) + OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState) OnConfigChange func(config *NetworkConfig) NetworkConfig *NetworkConfig } @@ -94,7 +94,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS _ = s.updateNtpServersFromLease(lease) _ = s.setHostnameIfNotSame() - opts.OnDhcpLeaseChange(lease) + opts.OnDhcpLeaseChange(lease, s) }, }) @@ -239,6 +239,10 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { ipv4Addresses = append(ipv4Addresses, addr.IP) ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) } else if addr.IP.To16() != nil { + if s.config.IPv6Mode.String == "disabled" { + continue + } + scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() // check if it's a link local address if addr.IP.IsLinkLocalUnicast() { @@ -287,35 +291,37 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } s.ipv4Addresses = ipv4AddressesString - if ipv6LinkLocal != nil { - if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() - if s.ipv6LinkLocal != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6LinkLocal.String()). - Msg("IPv6 link local address changed") - } else { - scopedLogger.Info().Msg("IPv6 link local address found") + if s.config.IPv6Mode.String != "disabled" { + if ipv6LinkLocal != nil { + if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() + if s.ipv6LinkLocal != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6LinkLocal.String()). + Msg("IPv6 link local address changed") + } else { + scopedLogger.Info().Msg("IPv6 link local address found") + } + s.ipv6LinkLocal = ipv6LinkLocal + changed = true } - s.ipv6LinkLocal = ipv6LinkLocal - changed = true } - } - s.ipv6Addresses = ipv6Addresses + s.ipv6Addresses = ipv6Addresses - if len(ipv6Addresses) > 0 { - // compare the addresses to see if there's a change - if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() - if s.ipv6Addr != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6Addr.String()). - Msg("IPv6 address changed") - } else { - scopedLogger.Info().Msg("IPv6 address found") + if len(ipv6Addresses) > 0 { + // compare the addresses to see if there's a change + if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() + if s.ipv6Addr != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6Addr.String()). + Msg("IPv6 address changed") + } else { + scopedLogger.Info().Msg("IPv6 address found") + } + s.ipv6Addr = &ipv6Addresses[0].Address + changed = true } - s.ipv6Addr = &ipv6Addresses[0].Address - changed = true } } diff --git a/internal/network/rpc.go b/internal/network/rpc.go index 32f34f57..62f21be8 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -65,7 +65,7 @@ func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string { func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { ipv6Addresses := make([]RpcIPv6Address, 0) - if s.ipv6Addresses != nil { + if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" { for _, addr := range s.ipv6Addresses { ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ Address: addr.Prefix.String(), diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index c32de2a2..b9ffa249 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -9,17 +9,32 @@ import ( "github.com/beevik/ntp" ) -var defaultNTPServers = []string{ +var defaultNTPServerIPs = []string{ + // These servers are known by static IP and as such don't need DNS lookups + // These are from Google and Cloudflare since if they're down, the internet + // is broken anyway + "162.159.200.1", // time.cloudflare.com IPv4 + "162.159.200.123", // time.cloudflare.com IPv4 + "2606:4700:f1::1", // time.cloudflare.com IPv6 + "2606:4700:f1::123", // time.cloudflare.com IPv6 + "216.239.35.0", // time.google.com IPv4 + "216.239.35.4", // time.google.com IPv4 + "216.239.35.8", // time.google.com IPv4 + "216.239.35.12", // time.google.com IPv4 + "2001:4860:4806::", // time.google.com IPv6 + "2001:4860:4806:4::", // time.google.com IPv6 + "2001:4860:4806:8::", // time.google.com IPv6 + "2001:4860:4806:c::", // time.google.com IPv6 +} + +var defaultNTPServerHostnames = []string{ + // should use something from https://github.com/jauderho/public-ntp-servers "time.apple.com", "time.aws.com", "time.windows.com", "time.google.com", - "162.159.200.123", // time.cloudflare.com IPv4 - "2606:4700:f1::123", // time.cloudflare.com IPv6 - "0.pool.ntp.org", - "1.pool.ntp.org", - "2.pool.ntp.org", - "3.pool.ntp.org", + "time.cloudflare.com", + "pool.ntp.org", } func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) { diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index db1c96ee..b29a61ab 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -158,6 +158,7 @@ func (t *TimeSync) Sync() error { var ( now *time.Time offset *time.Duration + log zerolog.Logger ) metricTimeSyncCount.Inc() @@ -166,54 +167,54 @@ func (t *TimeSync) Sync() error { Orders: for _, mode := range syncMode.Ordering { + log = t.l.With().Str("mode", mode).Logger() switch mode { case "ntp_user_provided": if syncMode.Ntp { - t.l.Info().Msg("using NTP custom servers") + log.Info().Msg("using NTP custom servers") now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers) if now != nil { - t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained") break Orders } } case "ntp_dhcp": if syncMode.Ntp { - t.l.Info().Msg("using NTP servers from DHCP") + log.Info().Msg("using NTP servers from DHCP") now, offset = t.queryNetworkTime(t.dhcpNtpAddresses) if now != nil { - t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained") break Orders } } case "ntp": if syncMode.Ntp && syncMode.NtpUseFallback { - t.l.Info().Msg("using NTP fallback") - now, offset = t.queryNetworkTime(defaultNTPServers) + log.Info().Msg("using NTP fallback IPs") + now, offset = t.queryNetworkTime(defaultNTPServerIPs) + if now == nil { + log.Info().Msg("using NTP fallback hostnames") + now, offset = t.queryNetworkTime(defaultNTPServerHostnames) + } if now != nil { - t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained") break Orders } } case "http_user_provided": if syncMode.Http { - t.l.Info().Msg("using HTTP custom URLs") + log.Info().Msg("using HTTP custom URLs") now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls) if now != nil { - t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained") break Orders } } case "http": if syncMode.Http && syncMode.HttpUseFallback { - t.l.Info().Msg("using HTTP fallback") + log.Info().Msg("using HTTP fallback") now = t.queryAllHttpTime(defaultHTTPUrls) if now != nil { - t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained") break Orders } } default: - t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping") + log.Warn().Msg("unknown time sync mode, skipping") } } @@ -226,6 +227,8 @@ Orders: now = &newNow } + log.Info().Time("now", *now).Msg("time obtained") + err := t.setSystemTime(*now) if err != nil { return fmt.Errorf("failed to set system time: %w", err) diff --git a/internal/usbgadget/consts.go b/internal/usbgadget/consts.go index 8204d0aa..958aecca 100644 --- a/internal/usbgadget/consts.go +++ b/internal/usbgadget/consts.go @@ -1,3 +1,7 @@ package usbgadget +import "time" + const dwc3Path = "/sys/bus/platform/drivers/dwc3" + +const hidWriteTimeout = 10 * time.Millisecond diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index f4fbaa6e..74cf76f9 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -5,7 +5,11 @@ import ( "context" "fmt" "os" + "sync" "time" + + "github.com/rs/xid" + "github.com/rs/zerolog" ) var keyboardConfig = gadgetConfigItem{ @@ -86,6 +90,12 @@ type KeyboardState struct { 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 { @@ -97,6 +107,7 @@ func getKeyboardState(b byte) KeyboardState { Compose: b&KeyboardLedMaskCompose != 0, Kana: b&KeyboardLedMaskKana != 0, Shift: b&KeyboardLedMaskShift != 0, + raw: b, } } @@ -138,25 +149,95 @@ func (u *UsbGadget) GetKeysDownState() KeysDownState { return u.keysDownState } -func (u *UsbGadget) updateKeyDownState(state KeysDownState) { - u.keyboardStateLock.Lock() - defer u.keyboardStateLock.Unlock() +func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) { + u.onKeysDownChange = &f +} - if u.keysDownState.Modifier == state.Modifier && - bytes.Equal(u.keysDownState.Keys, state.Keys) { - return // No change in key down state +func (u *UsbGadget) SetOnKeepAliveReset(f func()) { + u.onKeepAliveReset = &f +} + +// DefaultAutoReleaseDuration is the default duration for auto-release of a key. +const DefaultAutoReleaseDuration = 100 * time.Millisecond + +func (u *UsbGadget) scheduleAutoRelease(key byte) { + u.kbdAutoReleaseLock.Lock() + defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease scheduled") + + if u.kbdAutoReleaseTimers[key] != nil { + u.kbdAutoReleaseTimers[key].Stop() } - u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated") - u.keysDownState = state + // TODO: make this configurable + // We currently hardcode the duration to 100ms + // However, it should be the same as the duration of the keep-alive reset called baseExtension. + u.kbdAutoReleaseTimers[key] = time.AfterFunc(100*time.Millisecond, func() { + u.performAutoRelease(key) + }) +} - if u.onKeysDownChange != nil { - (*u.onKeysDownChange)(state) +func (u *UsbGadget) cancelAutoRelease(key byte) { + u.kbdAutoReleaseLock.Lock() + defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease cancelled") + + if timer := u.kbdAutoReleaseTimers[key]; timer != nil { + timer.Stop() + u.kbdAutoReleaseTimers[key] = nil + delete(u.kbdAutoReleaseTimers, key) + + // Reset keep-alive timing when key is released + if u.onKeepAliveReset != nil { + (*u.onKeepAliveReset)() + } } } -func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) { - u.onKeysDownChange = &f +func (u *UsbGadget) DelayAutoReleaseWithDuration(resetDuration time.Duration) { + u.kbdAutoReleaseLock.Lock() + defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease delayed") + + u.log.Debug().Dur("reset_duration", resetDuration).Msg("delaying auto-release with dynamic duration") + + for _, timer := range u.kbdAutoReleaseTimers { + if timer != nil { + timer.Reset(resetDuration) + } + } +} + +func (u *UsbGadget) performAutoRelease(key byte) { + u.kbdAutoReleaseLock.Lock() + + if u.kbdAutoReleaseTimers[key] == nil { + u.log.Warn().Uint8("key", key).Msg("autoRelease timer not found") + u.kbdAutoReleaseLock.Unlock() + return + } + + u.kbdAutoReleaseTimers[key].Stop() + u.kbdAutoReleaseTimers[key] = nil + delete(u.kbdAutoReleaseTimers, key) + u.kbdAutoReleaseLock.Unlock() + + // Skip if already released + state := u.GetKeysDownState() + alreadyReleased := true + + for i := range state.Keys { + if state.Keys[i] == key { + alreadyReleased = false + break + } + } + + if alreadyReleased { + return + } + + _, err := u.keypressReport(key, false) + if err != nil { + u.log.Warn().Uint8("key", key).Msg("failed to release key") + } } func (u *UsbGadget) listenKeyboardEvents() { @@ -228,12 +309,16 @@ func (u *UsbGadget) OpenKeyboardHidFile() error { return u.openKeyboardHidFile() } +var keyboardWriteHidFileLock sync.Mutex + func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { + keyboardWriteHidFileLock.Lock() + defer keyboardWriteHidFileLock.Unlock() if err := u.openKeyboardHidFile(); err != nil { return err } - _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) + _, 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") u.keyboardHidFile.Close() @@ -252,17 +337,29 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { } } - downState := KeysDownState{ + state := KeysDownState{ Modifier: modifier, Keys: []byte(keys[:]), } - u.updateKeyDownState(downState) - return downState + + u.keyboardStateLock.Lock() + + if u.keysDownState.Modifier == state.Modifier && + bytes.Equal(u.keysDownState.Keys, state.Keys) { + u.keyboardStateLock.Unlock() + return state // No change in key down state + } + + u.keysDownState = state + u.keyboardStateLock.Unlock() + + if u.onKeysDownChange != nil { + (*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...) + } + return state } -func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) { - u.keyboardLock.Lock() - defer u.keyboardLock.Unlock() +func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { defer u.resetUserInputTime() if len(keys) > hidKeyBufferSize { @@ -277,7 +374,8 @@ func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, e u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0") } - return u.UpdateKeysDown(modifier, keys), err + u.UpdateKeysDown(modifier, keys) + return err } const ( @@ -317,17 +415,23 @@ var KeyCodeToMaskMap = map[byte]byte{ RightSuper: ModifierMaskRightSuper, } -func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) { - u.keyboardLock.Lock() - defer u.keyboardLock.Unlock() +func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error) { defer u.resetUserInputTime() + l := u.log.With().Uint8("key", key).Bool("press", press).Logger() + if l.GetLevel() <= zerolog.DebugLevel { + requestID := xid.New() + l = l.With().Str("requestID", requestID.String()).Logger() + } + // 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 + var state = u.GetKeysDownState() + l.Trace().Interface("state", state).Msg("got keys down state") + modifier := state.Modifier keys := append([]byte(nil), state.Keys...) @@ -367,22 +471,36 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) // 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") + l.Error().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") + l.Warn().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 } + +func (u *UsbGadget) KeypressReport(key byte, press bool) error { + state, err := u.keypressReport(key, press) + if err != nil { + u.log.Warn().Uint8("key", key).Bool("press", press).Msg("failed to report key") + } + isRolledOver := state.Keys[0] == hidErrorRollOver + + if isRolledOver { + u.cancelAutoRelease(key) + } else if press { + u.scheduleAutoRelease(key) + } else { + u.cancelAutoRelease(key) + } + + return err +} diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index c083b606..374844f1 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -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") u.absMouseHidFile.Close() diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 70cb72c5..070db6e8 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -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") u.relMouseHidFile.Close() diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index 3a01a447..f01ae09d 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -68,6 +68,9 @@ type UsbGadget struct { keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys) + kbdAutoReleaseLock sync.Mutex + kbdAutoReleaseTimers map[byte]*time.Timer + keyboardStateLock sync.Mutex keyboardStateCtx context.Context keyboardStateCancel context.CancelFunc @@ -85,6 +88,7 @@ type UsbGadget struct { onKeyboardStateChange *func(state KeyboardState) onKeysDownChange *func(state KeysDownState) + onKeepAliveReset *func() log *zerolog.Logger @@ -118,23 +122,24 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev keyboardCtx, keyboardCancel := context.WithCancel(context.Background()) g := &UsbGadget{ - name: name, - kvmGadgetPath: path.Join(gadgetPath, name), - configC1Path: path.Join(gadgetPath, name, "configs/c.1"), - configMap: configMap, - customConfig: *config, - configLock: sync.Mutex{}, - keyboardLock: sync.Mutex{}, - absMouseLock: sync.Mutex{}, - relMouseLock: sync.Mutex{}, - txLock: sync.Mutex{}, - keyboardStateCtx: keyboardCtx, - keyboardStateCancel: keyboardCancel, - 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, + name: name, + kvmGadgetPath: path.Join(gadgetPath, name), + configC1Path: path.Join(gadgetPath, name, "configs/c.1"), + configMap: configMap, + customConfig: *config, + configLock: sync.Mutex{}, + keyboardLock: sync.Mutex{}, + absMouseLock: sync.Mutex{}, + relMouseLock: sync.Mutex{}, + txLock: sync.Mutex{}, + keyboardStateCtx: keyboardCtx, + keyboardStateCancel: keyboardCancel, + keyboardState: 0, + keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes + kbdAutoReleaseTimers: make(map[byte]*time.Timer), + enabledDevices: *enabledDevices, + lastUserInput: time.Now(), + log: logger, strictMode: config.strictMode, @@ -149,3 +154,37 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev return g } + +// Close cleans up resources used by the USB gadget +func (u *UsbGadget) Close() error { + // Cancel keyboard state context + if u.keyboardStateCancel != nil { + u.keyboardStateCancel() + } + + // Stop auto-release timer + u.kbdAutoReleaseLock.Lock() + for _, timer := range u.kbdAutoReleaseTimers { + if timer != nil { + timer.Stop() + } + } + u.kbdAutoReleaseTimers = make(map[byte]*time.Timer) + u.kbdAutoReleaseLock.Unlock() + + // Close HID files + if u.keyboardHidFile != nil { + u.keyboardHidFile.Close() + u.keyboardHidFile = nil + } + if u.absMouseHidFile != nil { + u.absMouseHidFile.Close() + u.absMouseHidFile = nil + } + if u.relMouseHidFile != nil { + u.relMouseHidFile.Close() + u.relMouseHidFile = nil + } + + return nil +} diff --git a/internal/usbgadget/utils.go b/internal/usbgadget/utils.go index 05fcd3ad..85bf1579 100644 --- a/internal/usbgadget/utils.go +++ b/internal/usbgadget/utils.go @@ -3,10 +3,14 @@ package usbgadget import ( "bytes" "encoding/json" + "errors" "fmt" + "os" "path/filepath" "strconv" "strings" + "sync" + "time" "github.com/rs/zerolog" ) @@ -107,6 +111,37 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) return false } +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 + } + + u.log.Trace(). + Str("file", file.Name()). + Bytes("data", data). + Err(err). + Msg("write failed") + + 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() @@ -136,3 +171,8 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) { u.logSuppressionCounter[counterName] = 0 } } + +func unlockWithLog(lock *sync.Mutex, logger *zerolog.Logger, msg string, args ...any) { + logger.Trace().Msgf(msg, args...) + lock.Unlock() +} diff --git a/internal/utils/ssh.go b/internal/utils/ssh.go new file mode 100644 index 00000000..9b9e874a --- /dev/null +++ b/internal/utils/ssh.go @@ -0,0 +1,73 @@ +package utils + +import ( + "fmt" + "slices" + "strings" + + "golang.org/x/crypto/ssh" +) + +// ValidSSHKeyTypes is a list of valid SSH key types +// +// Please make sure that all the types in this list are supported by dropbear +// https://github.com/mkj/dropbear/blob/003c5fcaabc114430d5d14142e95ffdbbd2d19b6/src/signkey.c#L37 +// +// ssh-dss is not allowed here as it's insecure +var ValidSSHKeyTypes = []string{ + ssh.KeyAlgoRSA, + ssh.KeyAlgoED25519, + ssh.KeyAlgoECDSA256, + ssh.KeyAlgoECDSA384, + ssh.KeyAlgoECDSA521, + ssh.KeyAlgoSKED25519, + ssh.KeyAlgoSKECDSA256, +} + +// ValidateSSHKey validates authorized_keys file content +func ValidateSSHKey(sshKey string) error { + // validate SSH key + var ( + hasValidPublicKey = false + lastError = fmt.Errorf("no valid SSH key found") + ) + for _, key := range strings.Split(sshKey, "\n") { + key = strings.TrimSpace(key) + + // skip empty lines and comments + if key == "" || strings.HasPrefix(key, "#") { + continue + } + + parsedPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) + if err != nil { + lastError = err + continue + } + + if parsedPublicKey == nil { + continue + } + + parsedType := parsedPublicKey.Type() + textType := strings.Fields(key)[0] + + if parsedType != textType { + lastError = fmt.Errorf("parsed SSH key type %s does not match type in text %s", parsedType, textType) + continue + } + + if !slices.Contains(ValidSSHKeyTypes, parsedType) { + lastError = fmt.Errorf("invalid SSH key type: %s", parsedType) + continue + } + + hasValidPublicKey = true + } + + if !hasValidPublicKey { + return lastError + } + + return nil +} diff --git a/internal/utils/ssh_test.go b/internal/utils/ssh_test.go new file mode 100644 index 00000000..7502032b --- /dev/null +++ b/internal/utils/ssh_test.go @@ -0,0 +1,220 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestValidateSSHKey(t *testing.T) { + tests := []struct { + name string + sshKey string + expectError bool + errorMsg string + }{ + { + name: "valid RSA key", + sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com", + expectError: false, + }, + { + name: "valid ED25519 key", + sshKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com", + expectError: false, + }, + { + name: "valid ECDSA key", + sshKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAlTkxIo4mXBR+gEX0Q74BpYX4bFFHoX+8Uz7tsob8HvsnMvsEE+BW9h9XrbWX4/4ppL/o6sHbvsqNr9HcyKfdc= test@example.com", + expectError: false, + }, + { + name: "valid SK-backed ED25519 key", + sshKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIHHSRVC3qISk/mOorf24au6esimA9Uu1/BkEnVKJ+4bFAAAABHNzaDo= test@example.com", + expectError: false, + }, + { + name: "valid SK-backed ECDSA key", + sshKey: "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBL/CFBZksvs+gJODMB9StxnkY6xRKH73npOzJBVb0UEGCPTAhDrvzW1PE5X5GDYXmZw1s7c/nS+GH0LF0OFCpwAAAAAEc3NoOg== test@example.com", + expectError: false, + }, + { + name: "multiple valid keys", + sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com", + expectError: false, + }, + { + name: "valid key with comment", + sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com", + expectError: false, + }, + { + name: "valid key with options and comment (we don't support options yet)", + sshKey: "command=\"echo hello\" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com", + expectError: true, + }, + { + name: "empty string", + sshKey: "", + expectError: true, + errorMsg: "no valid SSH key found", + }, + { + name: "whitespace only", + sshKey: " \n\t \n ", + expectError: true, + errorMsg: "no valid SSH key found", + }, + { + name: "comment only", + sshKey: "# This is a comment\n# Another comment", + expectError: true, + errorMsg: "no valid SSH key found", + }, + { + name: "invalid key format", + sshKey: "not-a-valid-ssh-key", + expectError: true, + }, + { + name: "invalid key type", + sshKey: "ssh-dss AAAAB3NzaC1kc3MAAACBAOeB...", + expectError: true, + errorMsg: "invalid SSH key type: ssh-dss", + }, + { + name: "unsupported key type", + sshKey: "ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA...", + expectError: true, + errorMsg: "invalid SSH key type: ssh-rsa-cert-v01@openssh.com", + }, + { + name: "malformed key data", + sshKey: "ssh-rsa invalid-base64-data", + expectError: true, + }, + { + name: "type mismatch", + sshKey: "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIGomKoH...", + expectError: true, + errorMsg: "parsed SSH key type ssh-ed25519 does not match type in text ssh-rsa", + }, + { + name: "mixed valid and invalid keys", + sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\ninvalid-key\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com", + expectError: false, + }, + { + name: "valid key with empty lines and comments", + sshKey: "# Comment line\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\n# Another comment\n\t\n", + expectError: false, + }, + { + name: "all invalid keys", + sshKey: "invalid-key-1\ninvalid-key-2\nssh-dss AAAAB3NzaC1kc3MAAACBAOeB...", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSSHKey(tt.sshKey) + + if tt.expectError { + if err == nil { + t.Errorf("ValidateSSHKey() expected error but got none") + } else if tt.errorMsg != "" && !strings.ContainsAny(err.Error(), tt.errorMsg) { + t.Errorf("ValidateSSHKey() error = %v, expected to contain %v", err, tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("ValidateSSHKey() unexpected error = %v", err) + } + } + }) + } +} + +func TestValidSSHKeyTypes(t *testing.T) { + expectedTypes := []string{ + "ssh-rsa", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ecdsa-sha2-nistp256@openssh.com", + "sk-ssh-ed25519@openssh.com", + } + + if len(ValidSSHKeyTypes) != len(expectedTypes) { + t.Errorf("ValidSSHKeyTypes length = %d, expected %d", len(ValidSSHKeyTypes), len(expectedTypes)) + } + + for _, expectedType := range expectedTypes { + found := false + for _, actualType := range ValidSSHKeyTypes { + if actualType == expectedType { + found = true + break + } + } + if !found { + t.Errorf("ValidSSHKeyTypes missing expected type: %s", expectedType) + } + } +} + +// TestValidateSSHKeyEdgeCases tests edge cases and boundary conditions +func TestValidateSSHKeyEdgeCases(t *testing.T) { + tests := []struct { + name string + sshKey string + expectError bool + }{ + { + name: "key with only type", + sshKey: "ssh-rsa", + expectError: true, + }, + { + name: "key with type and empty data", + sshKey: "ssh-rsa ", + expectError: true, + }, + { + name: "key with type and whitespace data", + sshKey: "ssh-rsa \t ", + expectError: true, + }, + { + name: "key with multiple spaces between type and data", + sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com", + expectError: false, + }, + { + name: "key with tabs", + sshKey: "\tssh-rsa\tAAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com", + expectError: false, + }, + { + name: "very long line", + sshKey: "ssh-rsa " + string(make([]byte, 10000)), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSSHKey(tt.sshKey) + + if tt.expectError { + if err == nil { + t.Errorf("ValidateSSHKey() expected error but got none") + } + } else { + if err != nil { + t.Errorf("ValidateSSHKey() unexpected error = %v", err) + } + } + }) + } +} diff --git a/jiggler.go b/jiggler.go index 52882c07..b2463e0a 100644 --- a/jiggler.go +++ b/jiggler.go @@ -17,16 +17,20 @@ type JigglerConfig struct { Timezone string `json:"timezone,omitempty"` } -var jigglerEnabled = false var jobDelta time.Duration = 0 var scheduler gocron.Scheduler = nil -func rpcSetJigglerState(enabled bool) { - jigglerEnabled = enabled +func rpcSetJigglerState(enabled bool) error { + config.JigglerEnabled = enabled + err := SaveConfig() + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil } func rpcGetJigglerState() bool { - return jigglerEnabled + return config.JigglerEnabled } func rpcGetTimezones() []string { @@ -118,7 +122,7 @@ func runJigglerCronTab() error { } func runJiggler() { - if jigglerEnabled { + if config.JigglerEnabled { if config.JigglerConfig.JitterPercentage != 0 { jitter := calculateJitterDuration(jobDelta) time.Sleep(jitter) diff --git a/jsonrpc.go b/jsonrpc.go index 552779ae..e448f952 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1,6 +1,7 @@ package kvm import ( + "bytes" "context" "encoding/json" "errors" @@ -10,13 +11,16 @@ import ( "path/filepath" "reflect" "strconv" + "sync" "time" "github.com/pion/webrtc/v4" "github.com/rs/zerolog" "go.bug.st/serial" + "github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/usbgadget" + "github.com/jetkvm/kvm/internal/utils" ) type JSONRPCRequest struct { @@ -83,7 +87,7 @@ func writeJSONRPCEvent(event string, params any, 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 { @@ -278,6 +282,17 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) { return updateStatus, nil } +func rpcGetLocalVersion() (*LocalMetadata, error) { + systemVersion, appVersion, err := GetLocalVersion() + if err != nil { + return nil, fmt.Errorf("error getting local version: %w", err) + } + return &LocalMetadata{ + AppVersion: appVersion.String(), + SystemVersion: systemVersion.String(), + }, nil +} + func rpcTryUpdate() error { includePreRelease := config.IncludePreRelease go func() { @@ -429,21 +444,27 @@ func rpcGetSSHKeyState() (string, error) { } func rpcSetSSHKeyState(sshKey string) error { - if sshKey != "" { - // Create directory if it doesn't exist - if err := os.MkdirAll(sshKeyDir, 0700); err != nil { - return fmt.Errorf("failed to create SSH key directory: %w", err) - } - - // Write SSH key to file - if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil { - return fmt.Errorf("failed to write SSH key: %w", err) - } - } else { + if sshKey == "" { // Remove SSH key file if empty string is provided if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove SSH key file: %w", err) } + return nil + } + + // Validate SSH key + if err := utils.ValidateSSHKey(sshKey); err != nil { + return err + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(sshKeyDir, 0700); err != nil { + return fmt.Errorf("failed to create SSH key directory: %w", err) + } + + // Write SSH key to file + if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil { + return fmt.Errorf("failed to write SSH key: %w", err) } return nil @@ -1105,6 +1126,103 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +var ( + keyboardMacroCancel context.CancelFunc + keyboardMacroLock sync.Mutex +) + +// cancelKeyboardMacro cancels any ongoing keyboard macro execution +func cancelKeyboardMacro() { + keyboardMacroLock.Lock() + defer keyboardMacroLock.Unlock() + + if keyboardMacroCancel != nil { + keyboardMacroCancel() + logger.Info().Msg("canceled keyboard macro") + keyboardMacroCancel = nil + } +} + +func setKeyboardMacroCancel(cancel context.CancelFunc) { + keyboardMacroLock.Lock() + defer keyboardMacroLock.Unlock() + + keyboardMacroCancel = cancel +} + +func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { + cancelKeyboardMacro() + + ctx, cancel := context.WithCancel(context.Background()) + setKeyboardMacroCancel(cancel) + + s := hidrpc.KeyboardMacroState{ + State: true, + IsPaste: true, + } + + if currentSession != nil { + currentSession.reportHidRPCKeyboardMacroState(s) + } + + err := rpcDoExecuteKeyboardMacro(ctx, macro) + + setKeyboardMacroCancel(nil) + + s.State = false + if currentSession != nil { + currentSession.reportHidRPCKeyboardMacroState(s) + } + + return err +} + +func rpcCancelKeyboardMacro() { + cancelKeyboardMacro() +} + +var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize) + +func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool { + return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys) +} + +func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error { + logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro") + + for i, step := range macro { + delay := time.Duration(step.Delay) * time.Millisecond + + err := rpcKeyboardReport(step.Modifier, step.Keys) + if err != nil { + logger.Warn().Err(err).Msg("failed to execute keyboard macro") + return err + } + + // notify the device that the keyboard state is being cleared + if isClearKeyStep(step) { + gadget.UpdateKeysDown(0, keyboardClearStateKeys) + } + + // Use context-aware sleep that can be cancelled + select { + case <-time.After(delay): + // Sleep completed normally + case <-ctx.Done(): + // make sure keyboard state is reset + err := rpcKeyboardReport(0, keyboardClearStateKeys) + if err != nil { + logger.Warn().Err(err).Msg("failed to reset keyboard state") + } + + logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep") + return ctx.Err() + } + } + + return nil +} + var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "reboot": {Func: rpcReboot, Params: []string{"force"}}, @@ -1115,10 +1233,10 @@ var rpcHandlers = map[string]RPCHandler{ "getNetworkSettings": {Func: rpcGetNetworkSettings}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "renewDHCPLease": {Func: rpcRenewDHCPLease}, - "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState}, - "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "getKeyDownState": {Func: rpcGetKeysDownState}, + "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, + "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, @@ -1140,6 +1258,7 @@ var rpcHandlers = map[string]RPCHandler{ "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "getDevChannelState": {Func: rpcGetDevChannelState}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, + "getLocalVersion": {Func: rpcGetLocalVersion}, "getUpdateStatus": {Func: rpcGetUpdateStatus}, "tryUpdate": {Func: rpcTryUpdate}, "getDevModeState": {Func: rpcGetDevModeState}, diff --git a/log.go b/log.go index 1a091b15..2047bbfa 100644 --- a/log.go +++ b/log.go @@ -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") diff --git a/main.go b/main.go index c25d8b8f..b4de5c9d 100644 --- a/main.go +++ b/main.go @@ -96,16 +96,25 @@ func Main() { if !config.AutoUpdateEnabled { return } + + if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() { + logger.Debug().Msg("system time is not synced, will retry in 30 seconds") + time.Sleep(30 * time.Second) + continue + } + if currentSession != nil { logger.Debug().Msg("skipping update since a session is active") time.Sleep(1 * time.Minute) continue } + includePreRelease := config.IncludePreRelease err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease) if err != nil { logger.Warn().Err(err).Msg("failed to auto update") } + time.Sleep(1 * time.Hour) } }() diff --git a/mdns.go b/mdns.go index d7a3b553..4f9b49b1 100644 --- a/mdns.go +++ b/mdns.go @@ -13,10 +13,7 @@ func initMdns() error { networkState.GetHostname(), networkState.GetFQDN(), }, - ListenOptions: &mdns.MDNSListenOptions{ - IPv4: true, - IPv6: true, - }, + ListenOptions: config.NetworkConfig.GetMDNSMode(), }) if err != nil { return err diff --git a/network.go b/network.go index d4f46e7a..af8e50fb 100644 --- a/network.go +++ b/network.go @@ -15,7 +15,7 @@ var ( networkState *network.NetworkInterfaceState ) -func networkStateChanged() { +func networkStateChanged(isOnline bool) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(true) @@ -37,6 +37,13 @@ func networkStateChanged() { networkState.GetFQDN(), }, true) } + + // if the network is now online, trigger an NTP sync if still needed + if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) { + if err := timeSync.Sync(); err != nil { + logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change") + } + } } func initNetwork() error { @@ -48,13 +55,13 @@ func initNetwork() error { NetworkConfig: config.NetworkConfig, Logger: networkLogger, OnStateChange: func(state *network.NetworkInterfaceState) { - networkStateChanged() + networkStateChanged(state.IsOnline()) }, OnInitialCheck: func(state *network.NetworkInterfaceState) { - networkStateChanged() + networkStateChanged(state.IsOnline()) }, - OnDhcpLeaseChange: func(lease *udhcpc.Lease) { - networkStateChanged() + OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) { + networkStateChanged(state.IsOnline()) if currentSession == nil { return @@ -64,7 +71,15 @@ func initNetwork() error { }, OnConfigChange: func(networkConfig *network.NetworkConfig) { config.NetworkConfig = networkConfig - networkStateChanged() + networkStateChanged(false) + + if mDNS != nil { + _ = mDNS.SetListenOptions(networkConfig.GetMDNSMode()) + _ = mDNS.SetLocalNames([]string{ + networkState.GetHostname(), + networkState.GetFQDN(), + }, true) + } }, }) diff --git a/resource/jetkvm_native b/resource/jetkvm_native old mode 100644 new mode 100755 index a47288b9..f4ea2666 Binary files a/resource/jetkvm_native and b/resource/jetkvm_native differ diff --git a/resource/jetkvm_native.sha256 b/resource/jetkvm_native.sha256 index ceba8b20..5bec8574 100644 --- a/resource/jetkvm_native.sha256 +++ b/resource/jetkvm_native.sha256 @@ -1 +1 @@ -6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521 +a4fca98710932aaa2765b57404e080105190cfa3af69171f4b4d95d4b78f9af0 diff --git a/resource/netboot.xyz-multiarch.iso b/resource/netboot.xyz-multiarch.iso index c3a4527f..2691c728 100644 Binary files a/resource/netboot.xyz-multiarch.iso and b/resource/netboot.xyz-multiarch.iso differ diff --git a/scripts/update_netboot_xyz.sh b/scripts/update_netboot_xyz.sh new file mode 100755 index 00000000..901d47f3 --- /dev/null +++ b/scripts/update_netboot_xyz.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Exit immediately if a command exits with a non-zero status +set -e + +C_RST="$(tput sgr0)" +C_ERR="$(tput setaf 1)" +C_OK="$(tput setaf 2)" +C_WARN="$(tput setaf 3)" +C_INFO="$(tput setaf 5)" + +msg() { printf '%s%s%s\n' $2 "$1" $C_RST; } + +msg_info() { msg "$1" $C_INFO; } +msg_ok() { msg "$1" $C_OK; } +msg_err() { msg "$1" $C_ERR; } +msg_warn() { msg "$1" $C_WARN; } + +# Get the latest release information +msg_info "Getting latest release information ..." +LATEST_RELEASE=$(curl -s \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/netbootxyz/netboot.xyz/releases | jq ' + [.[] | select(.prerelease == false and .draft == false and .assets != null and (.assets | length > 0))] | + sort_by(.created_at) | + .[-1]') + +# Extract version, download URL, and digest +VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name') +ISO_URL=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .browser_download_url') +EXPECTED_CHECKSUM=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .digest' | sed 's/sha256://') + +msg_ok "Latest version: $VERSION" +msg_ok "ISO URL: $ISO_URL" +msg_ok "Expected SHA256: $EXPECTED_CHECKSUM" + + +# Check if we already have the same version +if [ -f "resource/netboot.xyz-multiarch.iso" ]; then + msg_info "Checking current resource file ..." + + # First check by checksum (fastest) + CURRENT_CHECKSUM=$(shasum -a 256 resource/netboot.xyz-multiarch.iso | awk '{print $1}') + + if [ "$CURRENT_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then + msg_ok "Resource file is already up to date (version $VERSION). No update needed." + exit 0 + else + msg_info "Checksums differ, proceeding with download ..." + fi +fi + +# Download ISO file +TMP_ISO=$(mktemp -t netbootxyziso) +msg_info "Downloading ISO file ..." +curl -L -o "$TMP_ISO" "$ISO_URL" + +# Verify SHA256 checksum +msg_info "Verifying SHA256 checksum ..." +ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_ISO" | awk '{print $1}') + +if [ "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM" ]; then + msg_ok "Verified SHA256 checksum." + mv -f "$TMP_ISO" "resource/netboot.xyz-multiarch.iso" + msg_ok "Updated ISO file." + git add "resource/netboot.xyz-multiarch.iso" + git commit -m "chore: update netboot.xyz-multiarch.iso to $VERSION" + msg_ok "Committed changes." + msg_ok "You can now push the changes to the remote repository." + exit 0 +else + msg_err "Inconsistent SHA256 checksum." + msg_err "Expected: $EXPECTED_CHECKSUM" + msg_err "Actual: $ACTUAL_CHECKSUM" + exit 1 +fi \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index 0ce91234..a798221c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,27 +6,34 @@ + JetKVM - + @@ -36,23 +43,21 @@ =6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -514,9 +505,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz", + "integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==", "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -766,31 +757,18 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1015,26 +993,43 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", "license": "MIT", - "engines": { - "node": ">=14.0.0" + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.0-beta.32", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", + "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz", - "integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", + "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", "cpu": [ "arm" ], @@ -1045,9 +1040,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz", - "integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", + "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", "cpu": [ "arm64" ], @@ -1058,9 +1053,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz", - "integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", + "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", "cpu": [ "arm64" ], @@ -1071,9 +1066,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz", - "integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", + "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", "cpu": [ "x64" ], @@ -1084,9 +1079,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz", - "integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", + "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", "cpu": [ "arm64" ], @@ -1097,9 +1092,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz", - "integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", + "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", "cpu": [ "x64" ], @@ -1110,9 +1105,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz", - "integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", + "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", "cpu": [ "arm" ], @@ -1123,9 +1118,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz", - "integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", + "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", "cpu": [ "arm" ], @@ -1136,9 +1131,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz", - "integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", + "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", "cpu": [ "arm64" ], @@ -1149,9 +1144,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz", - "integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", + "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", "cpu": [ "arm64" ], @@ -1162,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz", - "integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", + "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", "cpu": [ "loong64" ], @@ -1175,9 +1170,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz", - "integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", + "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", "cpu": [ "ppc64" ], @@ -1188,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz", - "integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", + "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", "cpu": [ "riscv64" ], @@ -1201,9 +1196,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz", - "integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", + "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", "cpu": [ "riscv64" ], @@ -1214,9 +1209,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz", - "integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", + "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", "cpu": [ "s390x" ], @@ -1227,9 +1222,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz", - "integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", + "integrity": "sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==", "cpu": [ "x64" ], @@ -1240,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz", - "integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.0.tgz", + "integrity": "sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==", "cpu": [ "x64" ], @@ -1252,10 +1247,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", + "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz", - "integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", + "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", "cpu": [ "arm64" ], @@ -1266,9 +1274,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz", - "integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", + "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", "cpu": [ "ia32" ], @@ -1279,9 +1287,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz", - "integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", + "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", "cpu": [ "x64" ], @@ -1297,6 +1305,18 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -1773,6 +1793,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", @@ -1961,49 +2041,55 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", - "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz", - "integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==", + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" } }, "node_modules/@types/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validator": { - "version": "13.15.2", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", - "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", - "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", + "integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/type-utils": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/type-utils": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2017,7 +2103,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.41.0", + "@typescript-eslint/parser": "^8.42.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2033,16 +2119,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", - "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz", + "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "debug": "^4.3.4" }, "engines": { @@ -2058,14 +2144,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", - "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", + "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.41.0", - "@typescript-eslint/types": "^8.41.0", + "@typescript-eslint/tsconfig-utils": "^8.42.0", + "@typescript-eslint/types": "^8.42.0", "debug": "^4.3.4" }, "engines": { @@ -2080,14 +2166,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", - "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz", + "integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0" + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2098,9 +2184,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", - "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", + "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", "dev": true, "license": "MIT", "engines": { @@ -2115,15 +2201,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", - "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz", + "integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2140,9 +2226,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", - "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", "dev": true, "license": "MIT", "engines": { @@ -2154,16 +2240,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", - "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", + "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.41.0", - "@typescript-eslint/tsconfig-utils": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", + "@typescript-eslint/project-service": "8.42.0", + "@typescript-eslint/tsconfig-utils": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2209,16 +2295,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", - "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz", + "integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0" + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2233,13 +2319,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", - "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", + "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/types": "8.42.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2276,14 +2362,17 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", - "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.0.1.tgz", + "integrity": "sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.27", - "@swc/core": "^1.12.11" + "@rolldown/pluginutils": "1.0.0-beta.32", + "@swc/core": "^1.13.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" @@ -2647,9 +2736,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", - "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -2667,8 +2756,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001735", - "electron-to-chromium": "^1.5.204", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -2736,9 +2825,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001737", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", - "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "version": "1.0.30001739", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", + "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", "dev": true, "funding": [ { @@ -2815,6 +2904,15 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3041,9 +3139,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, "node_modules/debug": { @@ -3131,16 +3229,6 @@ "node": ">=0.10.0" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3156,9 +3244,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.209", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.209.tgz", - "integrity": "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A==", + "version": "1.5.213", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz", + "integrity": "sha512-xr9eRzSLNa4neDO0xVFrkXu3vyIzG4Ay08dApecw42Z1NbmCt+keEpXdvlYGVe0wtvY5dhW0Ay0lY0IOfsCg0Q==", "dev": true, "license": "ISC" }, @@ -3346,6 +3434,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -3790,9 +3888,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -3801,15 +3899,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4288,6 +4377,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4753,6 +4852,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5079,12 +5179,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", @@ -5121,6 +5215,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5307,6 +5402,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5712,6 +5808,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5719,6 +5816,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5810,84 +5914,67 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT", + "peer": true }, - "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" - }, - "engines": { - "node": ">=14.0.0" + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" }, "peerDependencies": { - "react": ">=16.8" + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } } }, - "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "node_modules/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-simple-keyboard": { - "version": "3.8.115", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.115.tgz", - "integrity": "sha512-tHN2J0Vpi/+lJaQFZMUBCZdZCRPCEMNklEXR4mt7M/s76vpzWMrwkZjxDRbmK++KUy0jIfbZ04v5kORgaWNEMQ==", + "version": "3.8.119", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.119.tgz", + "integrity": "sha512-gAc7cMImwVFmKuIjg5g5yEHZ94mDIQ7AXQiF41o1vZF0MR4Ayfg2CzNKbv8fdpDr7fCBq6g+x14bK6p9U0DGlA==", "license": "MIT", "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/react-use-websocket": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.13.0.tgz", @@ -5904,43 +5991,47 @@ } }, "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.1.2.tgz", + "integrity": "sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==", "license": "MIT", "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5983,6 +6074,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6024,9 +6121,9 @@ } }, "node_modules/rollup": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz", - "integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", + "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -6039,26 +6136,27 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.48.1", - "@rollup/rollup-android-arm64": "4.48.1", - "@rollup/rollup-darwin-arm64": "4.48.1", - "@rollup/rollup-darwin-x64": "4.48.1", - "@rollup/rollup-freebsd-arm64": "4.48.1", - "@rollup/rollup-freebsd-x64": "4.48.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.48.1", - "@rollup/rollup-linux-arm-musleabihf": "4.48.1", - "@rollup/rollup-linux-arm64-gnu": "4.48.1", - "@rollup/rollup-linux-arm64-musl": "4.48.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.48.1", - "@rollup/rollup-linux-ppc64-gnu": "4.48.1", - "@rollup/rollup-linux-riscv64-gnu": "4.48.1", - "@rollup/rollup-linux-riscv64-musl": "4.48.1", - "@rollup/rollup-linux-s390x-gnu": "4.48.1", - "@rollup/rollup-linux-x64-gnu": "4.48.1", - "@rollup/rollup-linux-x64-musl": "4.48.1", - "@rollup/rollup-win32-arm64-msvc": "4.48.1", - "@rollup/rollup-win32-ia32-msvc": "4.48.1", - "@rollup/rollup-win32-x64-msvc": "4.48.1", + "@rollup/rollup-android-arm-eabi": "4.50.0", + "@rollup/rollup-android-arm64": "4.50.0", + "@rollup/rollup-darwin-arm64": "4.50.0", + "@rollup/rollup-darwin-x64": "4.50.0", + "@rollup/rollup-freebsd-arm64": "4.50.0", + "@rollup/rollup-freebsd-x64": "4.50.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", + "@rollup/rollup-linux-arm-musleabihf": "4.50.0", + "@rollup/rollup-linux-arm64-gnu": "4.50.0", + "@rollup/rollup-linux-arm64-musl": "4.50.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", + "@rollup/rollup-linux-ppc64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-musl": "4.50.0", + "@rollup/rollup-linux-s390x-gnu": "4.50.0", + "@rollup/rollup-linux-x64-gnu": "4.50.0", + "@rollup/rollup-linux-x64-musl": "4.50.0", + "@rollup/rollup-openharmony-arm64": "4.50.0", + "@rollup/rollup-win32-arm64-msvc": "4.50.0", + "@rollup/rollup-win32-ia32-msvc": "4.50.0", + "@rollup/rollup-win32-x64-msvc": "4.50.0", "fsevents": "~2.3.2" } }, @@ -6157,6 +6255,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6519,13 +6623,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6827,9 +6931,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", @@ -6849,23 +6953,23 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6874,14 +6978,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" diff --git a/ui/package.json b/ui/package.json index 51de695b..f6aef35e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "kvm-ui", "private": true, - "version": "2025.08.25.2300", + "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", @@ -41,11 +41,11 @@ "react-dom": "^19.1.1", "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", - "react-router-dom": "^6.22.3", - "react-simple-keyboard": "^3.8.115", + "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", @@ -59,13 +59,13 @@ "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.12", - "@types/react": "^19.1.11", - "@types/react-dom": "^19.1.8", - "@types/semver": "^7.7.0", - "@types/validator": "^13.15.2", - "@typescript-eslint/eslint-plugin": "^8.41.0", - "@typescript-eslint/parser": "^8.41.0", - "@vitejs/plugin-react-swc": "^3.10.2", + "@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.34.0", "eslint-config-prettier": "^10.1.8", @@ -79,7 +79,7 @@ "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.12", "typescript": "^5.9.2", - "vite": "^6.3.5", + "vite": "^7.1.5", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/ui/public/robots.txt b/ui/public/robots.txt deleted file mode 100644 index 1f53798b..00000000 --- a/ui/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / diff --git a/ui/src/assets/attach-icon.svg b/ui/src/assets/attach-icon.svg deleted file mode 100644 index 88deb80a..00000000 --- a/ui/src/assets/attach-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/ui/src/components/AuthLayout.tsx b/ui/src/components/AuthLayout.tsx index 6c6d5da7..7d66e95c 100644 --- a/ui/src/components/AuthLayout.tsx +++ b/ui/src/components/AuthLayout.tsx @@ -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"; diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx index 97fcc5f6..b1dc3ab9 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -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( ({ 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" : "", ); diff --git a/ui/src/components/CustomTooltip.tsx b/ui/src/components/CustomTooltip.tsx index a27f6078..5b8848f6 100644 --- a/ui/src/components/CustomTooltip.tsx +++ b/ui/src/components/CustomTooltip.tsx @@ -1,25 +1,25 @@ import Card from "@components/Card"; export interface CustomTooltipProps { - payload: { payload: { date: number; stat: number }; unit: string }[]; + payload: { payload: { date: number; metric: number }; unit: string }[]; } export default function CustomTooltip({ payload }: CustomTooltipProps) { if (payload?.length) { const toolTipData = payload[0]; - const { date, stat } = toolTipData.payload; + const { date, metric } = toolTipData.payload; return ( -
-
+
+
{new Date(date * 1000).toLocaleTimeString()}
- - {stat} {toolTipData?.unit} + + {metric} {toolTipData?.unit}
diff --git a/ui/src/components/Fieldset.tsx b/ui/src/components/Fieldset.tsx index 9a37e79c..06b7ab9d 100644 --- a/ui/src/components/Fieldset.tsx +++ b/ui/src/components/Fieldset.tsx @@ -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, diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 4bb7a976..a650693f 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -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"; @@ -103,7 +103,7 @@ export default function DashboardNavbar({
- +
)} - + {debugMode && ( +
+ HidRPC State: + {rpcHidStatus} +
+ )} + {isPasteInProgress && ( +
+ Paste Mode: + Enabled +
+ )} {showPressedKeys && (
Keys: diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index a31b78e0..0cfacc6d 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
- {networkState?.dhcp_lease?.ip && ( + {networkState?.ipv6_link_local && (
Link-local diff --git a/ui/src/components/KvmCard.tsx b/ui/src/components/KvmCard.tsx index 91296c5c..ab34976b 100644 --- a/ui/src/components/KvmCard.tsx +++ b/ui/src/components/KvmCard.tsx @@ -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"; @@ -100,15 +100,12 @@ export default function KvmCard({ )}
-
- -
- + { + title: string; + description: string; + stream?: Map; + metric?: K; + data?: ChartPoint[]; + gate?: Map; + supported?: boolean; + map?: (p: { date: number; metric: number | null }) => ChartPoint; + domain?: [number, number]; + unit: string; + heightClassName?: string; + referenceValue?: number; + badge?: ComponentProps["badge"]; + badgeTheme?: ComponentProps["badgeTheme"]; +} + +/** + * Creates a chart array from a metrics map and a metric name. + * + * @param metrics - Expected to be ordered from oldest to newest. + * @param metricName - Name of the metric to create a chart array for. + */ +export function createChartArray( + metrics: Map, + metricName: K, +) { + const result: { date: number; metric: number | null }[] = []; + const iter = metrics.entries(); + let next = iter.next() as IteratorResult<[number, T]>; + const nowSeconds = Math.floor(Date.now() / 1000); + + // We want 120 data points, in the chart. + const firstDate = Math.min(next.value?.[0] ?? nowSeconds, nowSeconds - 120); + + for (let t = firstDate; t < nowSeconds; t++) { + while (!next.done && next.value[0] < t) next = iter.next(); + const has = !next.done && next.value[0] === t; + + let metric = null; + if (has) metric = next.value[1][metricName] as number; + result.push({ date: t, metric }); + + if (has) next = iter.next(); + } + + return result; +} + +function computeReferenceValue(points: ChartPoint[]): number | undefined { + const values = points + .filter(p => p.metric != null && Number.isFinite(p.metric)) + .map(p => Number(p.metric)); + + if (values.length === 0) return undefined; + + const sum = values.reduce((acc, v) => acc + v, 0); + const mean = sum / values.length; + return Math.round(mean); +} + +const theme = { + light: + "bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300", + danger: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50", + primary: "bg-blue-500 dark:border-blue-700 dark:bg-blue-800 dark:text-blue-50", +}; + +interface SettingsItemProps { + readonly title: string; + readonly description: string | React.ReactNode; + readonly badge?: string; + readonly className?: string; + readonly children?: React.ReactNode; + readonly badgeTheme?: keyof typeof theme; +} + +export function MetricHeader(props: SettingsItemProps) { + const { title, description, badge } = props; + const badgeVariants = cva({ variants: { theme: theme } }); + + return ( +
+
+
+ {title} + {badge && ( + + {badge} + + )} +
+
+
{description}
+
+ ); +} + +export function Metric({ + title, + description, + stream, + metric, + data, + gate, + supported, + map, + domain = [0, 600], + unit = "", + heightClassName = "h-[127px]", + badge, + badgeTheme, +}: MetricProps) { + const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true; + const supportedFinal = + supported ?? + (stream && metric ? someIterable(stream, ([, s]) => s[metric] !== undefined) : true); + + // Either we let the consumer provide their own chartArray, or we create one from the stream and metric. + const raw = data ?? ((stream && metric && createChartArray(stream, metric)) || []); + + // If the consumer provides a map function, we apply it to the raw data. + const dataFinal: ChartPoint[] = map ? raw.map(map) : raw; + + // Compute the average value of the metric. + const referenceValue = computeReferenceValue(dataFinal); + + return ( +
+ + + +
+ {!ready ? ( +
+

Waiting for data...

+
+ ) : supportedFinal ? ( + + ) : ( +
+

Metric not supported

+
+ )} +
+
+
+ ); +} diff --git a/ui/src/components/StatChart.tsx b/ui/src/components/MetricsChart.tsx similarity index 91% rename from ui/src/components/StatChart.tsx rename to ui/src/components/MetricsChart.tsx index 2c403e33..853bcf38 100644 --- a/ui/src/components/StatChart.tsx +++ b/ui/src/components/MetricsChart.tsx @@ -12,13 +12,13 @@ import { import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; -export default function StatChart({ +export default function MetricsChart({ data, domain, unit, referenceValue, }: { - data: { date: number; stat: number | null | undefined }[]; + data: { date: number; metric: number | null | undefined }[]; domain?: [string | number, string | number]; unit?: string; referenceValue?: number; @@ -33,7 +33,7 @@ export default function StatChart({ strokeLinecap="butt" stroke="rgba(30, 41, 59, 0.1)" /> - {referenceValue && ( + {referenceValue !== undefined && ( x.date)} /> = { "not attached": "Disconnected", suspended: "Low power mode", }; +const StatusCardProps: StatusProps = { + configured: { + icon: ({ className }) => ( + + ), + iconClassName: "h-5 w-5 shrink-0", + statusIndicatorClassName: "bg-green-500 border-green-600", + }, + attached: { + icon: ({ className }) => , + iconClassName: "h-5 w-5 text-blue-500", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + addressed: { + icon: ({ className }) => , + iconClassName: "h-5 w-5 text-blue-500", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + "not attached": { + icon: ({ className }) => ( + + ), + iconClassName: "h-5 w-5 opacity-50 grayscale filter", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + suspended: { + icon: ({ className }) => ( + + ), + iconClassName: "h-5 w-5 opacity-50 grayscale filter", + statusIndicatorClassName: "bg-green-500 border-green-600", + }, +}; export default function USBStateStatus({ state, @@ -30,39 +63,7 @@ export default function USBStateStatus({ state: USBStates; peerConnectionState?: RTCPeerConnectionState | null; }) { - const StatusCardProps: StatusProps = { - configured: { - icon: ({ className }) => ( - - ), - iconClassName: "h-5 w-5 shrink-0", - statusIndicatorClassName: "bg-green-500 border-green-600", - }, - attached: { - icon: ({ className }) => , - iconClassName: "h-5 w-5 text-blue-500", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - addressed: { - icon: ({ className }) => , - iconClassName: "h-5 w-5 text-blue-500", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - "not attached": { - icon: ({ className }) => ( - - ), - iconClassName: "h-5 w-5 opacity-50 grayscale filter", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - suspended: { - icon: ({ className }) => ( - - ), - iconClassName: "h-5 w-5 opacity-50 grayscale filter", - statusIndicatorClassName: "bg-green-500 border-green-600", - }, - }; + const props = StatusCardProps[state]; if (!props) { console.warn("Unsupported USB state: ", state); diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index ce1bd83f..83ebd72f 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -2,33 +2,31 @@ 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, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; -import { keys, modifiers, latchingKeys, decodeModifiers } from "@/keyboardMappings"; +import { decodeModifiers, keys, latchingKeys, modifiers } from "@/keyboardMappings"; export const DetachIcon = ({ className }: { className?: string }) => { return Detach Icon; }; -const AttachIcon = ({ className }: { className?: string }) => { - return Attach Icon; -}; - function KeyboardWrapper() { const keyboardRef = useRef(null); - const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); - const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = + useUiStore(); + const { keyboardLedState, keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = + useHidStore(); const { handleKeyPress, executeMacro } = useKeyboard(); const { selectedKeyboard } = useKeyboardLayout(); @@ -44,29 +42,34 @@ function KeyboardWrapper() { return selectedKeyboard.virtualKeyboard; }, [selectedKeyboard]); - //const isCapsLockActive = useMemo(() => { - // return (keyboardLedState.caps_lock); - //}, [keyboardLedState]); - - const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => { + const { isShiftActive } = useMemo(() => { return decodeModifiers(keysDownState.modifier); }, [keysDownState]); + const isCapsLockActive = useMemo(() => { + return keyboardLedState.caps_lock; + }, [keyboardLedState]); + const mainLayoutName = useMemo(() => { - const layoutName = isShiftActive ? "shift": "default"; - return layoutName; - }, [isShiftActive]); + // 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 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); + 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 + 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; if (e instanceof TouchEvent && e.touches.length > 1) return; @@ -110,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); @@ -134,15 +140,12 @@ 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 onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => { + e?.preventDefault(); + e?.stopPropagation(); + }, []); const onKeyDown = useCallback( async (key: string, e: MouseEvent | undefined) => { @@ -151,24 +154,30 @@ function KeyboardWrapper() { // handle the fake key-macros we have defined for common combinations if (key === "CtrlAltDelete") { - await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ + { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, + ]); return; } if (key === "AltMetaEscape") { - await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); + await executeMacro([ + { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 }, + ]); return; } if (key === "CtrlAltBackspace") { - await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ + { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, + ]); 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) + handleKeyPress(keys[key], true); setTimeout(() => handleKeyPress(keys[key], false), 100); return; } @@ -176,8 +185,10 @@ function KeyboardWrapper() { // 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) + console.debug( + `Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`, + ); + handleKeyPress(keys[key], !currentlyDown); return; } @@ -211,7 +222,7 @@ function KeyboardWrapper() {
-
+
{isAttachedVirtualKeyboardVisible ? (
-

+

Virtual Keyboard

-
+
+
+ +
+
+
- { /* TODO add optional number pad */ } + {/* TODO add optional number pad */}
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 9e2f0f24..64452bf8 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -7,15 +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 } from "@/keyboardMappings"; import { - useMouseStore, useRTCStore, useSettingsStore, useVideoStore, } from "@/hooks/stores"; +import useMouse from "@/hooks/useMouse"; import { HDMIErrorOverlay, @@ -31,10 +30,18 @@ export default function WebRTCVideo() { const [isPlaying, setIsPlaying] = useState(false); 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 { handleKeyPress, resetKeyboardState } = useKeyboard(); - const { setMousePosition, setMouseMove } = useMouseStore(); + const { + getRelMouseMoveHandler, + getAbsMouseMoveHandler, + getMouseWheelHandler, + resetMousePosition, + } = useMouse(); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -55,15 +62,9 @@ export default function WebRTCVideo() { const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; - // Mouse wheel states - 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 }) => { + ({ 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); @@ -99,7 +100,6 @@ export default function WebRTCVideo() { ); // 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) => { @@ -190,7 +190,7 @@ export default function WebRTCVideo() { 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 + // If keyboard lock is activated after fullscreen is already in effect, then the user my // see multiple messages about how to exit fullscreen. For this reason, we recommend that // developers call lock() before they enter fullscreen: await requestKeyboardLock(); @@ -211,132 +211,33 @@ export default function WebRTCVideo() { } }; - 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 mouseWheelHandler = useMemo( + () => getMouseWheelHandler(), + [getMouseWheelHandler], ); - 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]); - const keyDownHandler = useCallback( (e: KeyboardEvent) => { e.preventDefault(); + if (e.repeat) return; const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -357,7 +258,7 @@ export default function WebRTCVideo() { } console.debug(`Key down: ${hidKey}`); handleKeyPress(hidKey, true); - + 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 @@ -488,14 +389,16 @@ export default function WebRTCVideo() { 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, @@ -523,7 +426,16 @@ export default function WebRTCVideo() { abortController.abort(); }; }, - [absMouseMoveHandler, isPointerLockActive, isPointerLockPossible, mouseWheelHandler, relMouseMoveHandler, requestPointerLock, resetMousePosition, settings.mouseMode], + [ + isPointerLockActive, + isPointerLockPossible, + requestPointerLock, + absMouseMoveHandler, + relMouseMoveHandler, + mouseWheelHandler, + resetMousePosition, + settings.mouseMode, + ], ); const containerRef = useRef(null); diff --git a/ui/src/components/popovers/MountPopover.tsx b/ui/src/components/popovers/MountPopover.tsx index 13812930..8b6a8a55 100644 --- a/ui/src/components/popovers/MountPopover.tsx +++ b/ui/src/components/popovers/MountPopover.tsx @@ -6,7 +6,7 @@ import { 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"; diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 077759b7..6f224eb5 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -1,40 +1,44 @@ -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"; +import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuCornerDownLeft } from "react-icons/lu"; -import { Button } from "@components/Button"; -import { GridCard } from "@components/Card"; -import { TextAreaWithLabel } from "@components/TextArea"; -import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { cx } from "@/cva.config"; +import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; -import { keys, modifiers } from "@/keyboardMappings"; -import { KeyStroke } from "@/keyboardLayouts"; +import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import notifications from "@/notifications"; +import { Button } from "@components/Button"; +import { GridCard } from "@components/Card"; +import { InputFieldWithLabel } from "@components/InputField"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { TextAreaWithLabel } from "@components/TextArea"; -const hidKeyboardPayload = (modifier: number, keys: number[]) => { - return { modifier, keys }; -}; - -const modifierCode = (shift?: boolean, altRight?: boolean) => { - return (shift ? modifiers.ShiftLeft : 0) - | (altRight ? modifiers.AltRight : 0) -} -const noModifier = 0 +// uint32 max value / 4 +const pasteMaxLength = 1073741824; export default function PasteModal() { const TextAreaRef = useRef(null); - const { setPasteModeEnabled } = useHidStore(); + const { isPasteInProgress } = useHidStore(); const { setDisableVideoFocusTrap } = useUiStore(); const { send } = useJsonRpc(); - const { rpcDataChannel } = useRTCStore(); + const { executeMacro, cancelExecuteMacro } = useKeyboard(); const [invalidChars, setInvalidChars] = useState([]); + const [delayValue, setDelayValue] = useState(100); + const delay = useMemo(() => { + if (delayValue < 50 || delayValue > 65534) { + return 100; + } + return delayValue; + }, [delayValue]); const close = useClose(); + const debugMode = useSettingsStore(state => state.debugMode); + const delayClassName = useMemo(() => debugMode ? "" : "hidden", [debugMode]); + const { setKeyboardLayout } = useSettingsStore(); const { selectedKeyboard } = useKeyboardLayout(); @@ -46,21 +50,19 @@ export default function PasteModal() { }, [send, setKeyboardLayout]); const onCancelPasteMode = useCallback(() => { - setPasteModeEnabled(false); + cancelExecuteMacro(); setDisableVideoFocusTrap(false); setInvalidChars([]); - }, [setDisableVideoFocusTrap, setPasteModeEnabled]); + }, [setDisableVideoFocusTrap, cancelExecuteMacro]); const onConfirmPaste = useCallback(async () => { - setPasteModeEnabled(false); - setDisableVideoFocusTrap(false); - - if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; - if (!selectedKeyboard) return; + if (!TextAreaRef.current || !selectedKeyboard) return; const text = TextAreaRef.current.value; try { + const macroSteps: MacroStep[] = []; + for (const char of text) { const keyprops = selectedKeyboard.chars[char]; if (!keyprops) continue; @@ -70,39 +72,41 @@ export default function PasteModal() { // if this is an accented character, we need to send that accent FIRST if (accentKey) { - await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] }) + const accentModifiers: string[] = []; + if (accentKey.shift) accentModifiers.push("ShiftLeft"); + if (accentKey.altRight) accentModifiers.push("AltRight"); + + macroSteps.push({ + keys: [String(accentKey.key)], + modifiers: accentModifiers.length > 0 ? accentModifiers : null, + delay, + }); } // now send the actual key - await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]}); + const modifiers: string[] = []; + if (shift) modifiers.push("ShiftLeft"); + if (altRight) modifiers.push("AltRight"); + + macroSteps.push({ + keys: [String(key)], + modifiers: modifiers.length > 0 ? modifiers : null, + delay + }); // if what was requested was a dead key, we need to send an unmodified space to emit // just the accent character - if (deadKey) { - await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] }); - } + if (deadKey) macroSteps.push({ keys: ["Space"], modifiers: null, delay }); + } - // now send a message with no keys down to "release" the keys - await sendKeystroke({ modifier: 0, keys: [] }); + if (macroSteps.length > 0) { + await executeMacro(macroSteps); } } catch (error) { console.error("Failed to paste text:", error); notifications.error("Failed to paste text"); } - - async function sendKeystroke(stroke: KeyStroke) { - await new Promise((resolve, reject) => { - send( - "keyboardReport", - hidKeyboardPayload(stroke.modifier, stroke.keys), - params => { - if ("error" in params) return reject(params.error); - resolve(); - } - ); - }); - } - }, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]); + }, [selectedKeyboard, executeMacro, delay]); useEffect(() => { if (TextAreaRef.current) { @@ -122,19 +126,25 @@ export default function PasteModal() { />
-
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} onKeyDownCapture={e => e.stopPropagation()} + onKeyUpCapture={e => e.stopPropagation()} + > e.stopPropagation()} + maxLength={pasteMaxLength} onKeyDown={e => { e.stopPropagation(); if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { @@ -171,9 +181,31 @@ export default function PasteModal() { )}
+
+ { + setDelayValue(parseInt(e.target.value, 10)); + }} + /> + {delayValue < 50 || delayValue > 65534 && ( +
+ + + Delay must be between 50 and 65534 + +
+ )} +

- Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name} + Sending text using keyboard layout: {selectedKeyboard.isoCode}- + {selectedKeyboard.name}

@@ -181,7 +213,7 @@ export default function PasteModal() {
diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index 3faf81ba..a69cd94e 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -1,74 +1,40 @@ import { useInterval } from "usehooks-ts"; import SidebarHeader from "@/components/SidebarHeader"; -import { GridCard } from "@/components/Card"; import { useRTCStore, useUiStore } from "@/hooks/stores"; -import StatChart from "@/components/StatChart"; +import { someIterable } from "@/utils"; -function createChartArray( - stream: Map, - metric: K, -): { date: number; stat: T[K] | null }[] { - const stat = Array.from(stream).map(([key, stats]) => { - return { date: key, stat: stats[metric] }; - }); - - // Sort the dates to ensure they are in chronological order - const sortedStat = stat.map(x => x.date).sort((a, b) => a - b); - - // Determine the earliest statistic date - const earliestStat = sortedStat[0]; - - // Current time in seconds since the Unix epoch - const now = Math.floor(Date.now() / 1000); - - // Determine the starting point for the chart data - const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120; - - // Generate the chart array for the range between 'firstChartDate' and 'now' - return Array.from({ length: now - firstChartDate }, (_, i) => { - const currentDate = firstChartDate + i; - return { - date: currentDate, - // Find the statistic for 'currentDate', or use the last known statistic if none exists for that date - stat: stat.find(x => x.date === currentDate)?.stat ?? null, - }; - }); -} +import { createChartArray, Metric } from "../Metric"; +import { SettingsSectionHeader } from "../SettingsSectionHeader"; export default function ConnectionStatsSidebar() { const { sidebarView, setSidebarView } = useUiStore(); const { - mediaStream, - peerConnection, - inboundRtpStats, - appendInboundRtpStats, - candidatePairStats, - appendCandidatePairStats, - appendLocalCandidateStats, - appendRemoteCandidateStats, - appendDiskDataChannelStats, + mediaStream, + peerConnection, + inboundRtpStats: inboundVideoRtpStats, + appendInboundRtpStats: appendInboundVideoRtpStats, + candidatePairStats: iceCandidatePairStats, + appendCandidatePairStats, + appendLocalCandidateStats, + appendRemoteCandidateStats, + appendDiskDataChannelStats, } = useRTCStore(); - function isMetricSupported( - stream: Map, - metric: K, - ): boolean { - return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); - } - useInterval(function collectWebRTCStats() { (async () => { if (!mediaStream) return; + const videoTrack = mediaStream.getVideoTracks()[0]; if (!videoTrack) return; + const stats = await peerConnection?.getStats(); let successfulLocalCandidateId: string | null = null; let successfulRemoteCandidateId: string | null = null; stats?.forEach(report => { - if (report.type === "inbound-rtp") { - appendInboundRtpStats(report); + if (report.type === "inbound-rtp" && report.kind === "video") { + appendInboundVideoRtpStats(report); } else if (report.type === "candidate-pair" && report.nominated) { if (report.state === "succeeded") { successfulLocalCandidateId = report.localCandidateId; @@ -91,144 +57,133 @@ export default function ConnectionStatsSidebar() { })(); }, 500); + const jitterBufferDelay = createChartArray(inboundVideoRtpStats, "jitterBufferDelay"); + const jitterBufferEmittedCount = createChartArray( + inboundVideoRtpStats, + "jitterBufferEmittedCount", + ); + + const jitterBufferAvgDelayData = jitterBufferDelay.map((d, idx) => { + if (idx === 0) return { date: d.date, metric: null }; + const prevDelay = jitterBufferDelay[idx - 1]?.metric as number | null | undefined; + const currDelay = d.metric as number | null | undefined; + const prevCountEmitted = + (jitterBufferEmittedCount[idx - 1]?.metric as number | null | undefined) ?? null; + const currCountEmitted = + (jitterBufferEmittedCount[idx]?.metric as number | null | undefined) ?? null; + + if ( + prevDelay == null || + currDelay == null || + prevCountEmitted == null || + currCountEmitted == null + ) { + return { date: d.date, metric: null }; + } + + const deltaDelay = currDelay - prevDelay; + const deltaEmitted = currCountEmitted - prevCountEmitted; + + // Guard counter resets or no emitted frames + if (deltaDelay < 0 || deltaEmitted <= 0) { + return { date: d.date, metric: null }; + } + + const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000); + return { date: d.date, metric: valueMs }; + }); + return (
- {/* - The entire sidebar component is always rendered, with a display none when not visible - The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible - */} {sidebarView === "connection-stats" && ( -
-
-
-

- Packets Lost -

-

- Number of data packets lost during transmission. -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : isMetricSupported(inboundRtpStats, "packetsLost") ? ( - - ) : ( -
-

Metric not supported

-
- )} -
-
+
+ {/* Connection Group */} +
+ + ({ + date: x.date, + metric: x.metric != null ? Math.round(x.metric * 1000) : null, + })} + domain={[0, 600]} + unit=" ms" + />
-
-
-

- Round-Trip Time -

-

- Time taken for data to travel from source to destination and back -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? ( - { - return { - date: x.date, - stat: x.stat ? Math.round(x.stat * 1000) : null, - }; - })} - domain={[0, 600]} - unit=" ms" - /> - ) : ( -
-

Metric not supported

-
- )} -
-
-
-
-
-

- Jitter -

-

- Variation in packet delay, affecting video smoothness.{" "} -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : ( - { - return { - date: x.date, - stat: x.stat ? Math.round(x.stat * 1000) : null, - }; - })} - domain={[0, 300]} - unit=" ms" - /> - )} -
-
-
-
-
-

- Frames per second -

-

- Number of video frames displayed per second. -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : ( - { - return { - date: x.date, - stat: x.stat ? x.stat : null, - }; - }, - )} - domain={[0, 80]} - unit=" fps" - /> - )} -
-
+ + {/* Video Group */} +
+ + + {/* RTP Jitter */} + ({ + date: x.date, + metric: x.metric != null ? Math.round(x.metric * 1000) : null, + })} + domain={[0, 10]} + unit=" ms" + /> + + {/* Playback Delay */} + x.jitterBufferDelay != null, + ) && + someIterable( + inboundVideoRtpStats, + ([, x]) => x.jitterBufferEmittedCount != null, + ) + } + domain={[0, 30]} + unit=" ms" + /> + + {/* Packets Lost */} + + + {/* Frames Per Second */} +
)} diff --git a/ui/src/hooks/hidRpc.ts b/ui/src/hooks/hidRpc.ts new file mode 100644 index 00000000..823384ff --- /dev/null +++ b/ui/src/hooks/hidRpc.ts @@ -0,0 +1,448 @@ +import { hidKeyBufferSize, KeyboardLedState, KeysDownState } from "./stores"; + +export const HID_RPC_MESSAGE_TYPES = { + Handshake: 0x01, + KeyboardReport: 0x02, + PointerReport: 0x03, + WheelReport: 0x04, + KeypressReport: 0x05, + KeypressKeepAliveReport: 0x09, + MouseReport: 0x06, + KeyboardMacroReport: 0x07, + CancelKeyboardMacroReport: 0x08, + KeyboardLedState: 0x32, + KeysDownState: 0x33, + KeyboardMacroState: 0x34, +} + +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 & 0xFF, + ]); +}; + +const fromUint16toUint8 = (n: number) => { + if (n > 65535 || n < 0) { + throw new Error(`Number ${n} is not within the uint16 range`); + } + + return new Uint8Array([ + (n >> 8) & 0xFF, + n & 0xFF, + ]); +}; + +const fromUint32toUint8 = (n: number) => { + if (n > 4294967295 || n < 0) { + throw new Error(`Number ${n} is not within the uint32 range`); + } + + return new Uint8Array([ + (n >> 24) & 0xFF, + (n >> 16) & 0xFF, + (n >> 8) & 0xFF, + n & 0xFF, + ]); +}; + +const fromInt8ToUint8 = (n: number) => { + if (n < -128 || n > 127) { + throw new Error(`Number ${n} is not within the int8 range`); + } + + return n & 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 interface KeyboardMacroStep extends KeysDownState { + delay: number; +} + +export class KeyboardMacroReportMessage extends RpcMessage { + isPaste: boolean; + stepCount: number; + steps: KeyboardMacroStep[]; + + KEYS_LENGTH = hidKeyBufferSize; + + constructor(isPaste: boolean, stepCount: number, steps: KeyboardMacroStep[]) { + super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport); + this.isPaste = isPaste; + this.stepCount = stepCount; + this.steps = steps; + } + + marshal(): Uint8Array { + // validate if length is correct + if (this.stepCount !== this.steps.length) { + throw new Error(`Length ${this.stepCount} is not equal to the number of steps ${this.steps.length}`); + } + + const data = new Uint8Array(this.stepCount * 9 + 6); + data.set(new Uint8Array([ + this.messageType, + this.isPaste ? 1 : 0, + ...fromUint32toUint8(this.stepCount), + ]), 0); + + for (let i = 0; i < this.stepCount; i++) { + const step = this.steps[i]; + if (!withinUint8Range(step.modifier)) { + throw new Error(`Modifier ${step.modifier} is not within the uint8 range`); + } + + // Ensure the keys are within the KEYS_LENGTH range + const keys = step.keys; + if (keys.length > this.KEYS_LENGTH) { + throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`); + } else if (keys.length < this.KEYS_LENGTH) { + keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0)); + } + + for (const key of keys) { + if (!withinUint8Range(key)) { + throw new Error(`Key ${key} is not within the uint8 range`); + } + } + + const macroBinary = new Uint8Array([ + step.modifier, + ...keys, + ...fromUint16toUint8(step.delay), + ]); + const offset = 6 + i * 9; + + + data.set(macroBinary, offset); + } + + return data; + } +} + +export class KeyboardMacroStateMessage extends RpcMessage { + state: boolean; + isPaste: boolean; + + constructor(state: boolean, isPaste: boolean) { + super(HID_RPC_MESSAGE_TYPES.KeyboardMacroState); + this.state = state; + this.isPaste = isPaste; + } + + marshal(): Uint8Array { + return new Uint8Array([ + this.messageType, + this.state ? 1 : 0, + this.isPaste ? 1 : 0, + ]); + } + + public static unmarshal(data: Uint8Array): KeyboardMacroStateMessage | undefined { + if (data.length < 1) { + throw new Error(`Invalid keyboard macro state report message length: ${data.length}`); + } + + return new KeyboardMacroStateMessage(data[0] === 1, data[1] === 1); + } +} + +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 CancelKeyboardMacroReportMessage extends RpcMessage { + + constructor() { + super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport); + } + + marshal(): Uint8Array { + return new Uint8Array([this.messageType]); + } +} + +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 class KeypressKeepAliveMessage extends RpcMessage { + constructor() { + super(HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport); + } + + marshal(): Uint8Array { + return new Uint8Array([this.messageType]); + } +} + +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, + [HID_RPC_MESSAGE_TYPES.KeyboardMacroReport]: KeyboardMacroReportMessage, + [HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage, + [HID_RPC_MESSAGE_TYPES.KeyboardMacroState]: KeyboardMacroStateMessage, + [HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport]: KeypressKeepAliveMessage, +} + +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); +}; \ No newline at end of file diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index a6dc95b9..bfbbb26e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -105,6 +105,21 @@ export interface RTCState { setRpcDataChannel: (channel: RTCDataChannel) => void; rpcDataChannel: RTCDataChannel | null; + hidRpcDisabled: boolean; + setHidRpcDisabled: (disabled: boolean) => void; + + rpcHidProtocolVersion: number | null; + setRpcHidProtocolVersion: (version: number | null) => void; + + rpcHidChannel: RTCDataChannel | null; + setRpcHidChannel: (channel: RTCDataChannel) => void; + + rpcHidUnreliableChannel: RTCDataChannel | null; + setRpcHidUnreliableChannel: (channel: RTCDataChannel) => void; + + rpcHidUnreliableNonOrderedChannel: RTCDataChannel | null; + setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => void; + peerConnectionState: RTCPeerConnectionState | null; setPeerConnectionState: (state: RTCPeerConnectionState) => void; @@ -151,6 +166,21 @@ export const useRTCStore = create(set => ({ rpcDataChannel: null, setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), + hidRpcDisabled: false, + setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), + + rpcHidProtocolVersion: null, + setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), + + rpcHidChannel: null, + setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), + + rpcHidUnreliableChannel: null, + setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), + + rpcHidUnreliableNonOrderedChannel: null, + setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), + transceiver: null, setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), @@ -449,13 +479,10 @@ export interface HidState { keysDownState: KeysDownState; setKeysDownState: (state: KeysDownState) => void; - keyPressReportApiAvailable: boolean; - setkeyPressReportApiAvailable: (available: boolean) => void; - isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; - isPasteModeEnabled: boolean; + isPasteInProgress: boolean; setPasteModeEnabled: (enabled: boolean) => void; usbState: USBStates; @@ -463,20 +490,17 @@ export interface HidState { } export const useHidStore = create(set => ({ - keyboardLedState: {} as KeyboardLedState, + 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 }), keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), - keyPressReportApiAvailable: true, - setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }), - isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), - isPasteModeEnabled: false, - setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }), + isPasteInProgress: false, + setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), // Add these new properties for USB state usbState: "not attached", diff --git a/ui/src/hooks/useAppNavigation.ts b/ui/src/hooks/useAppNavigation.ts index 6c9270a1..af9a247d 100644 --- a/ui/src/hooks/useAppNavigation.ts +++ b/ui/src/hooks/useAppNavigation.ts @@ -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"; diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts new file mode 100644 index 00000000..aeb1c4fa --- /dev/null +++ b/ui/src/hooks/useHidRpc.ts @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useMemo } from "react"; + +import { useRTCStore } from "@/hooks/stores"; + +import { + CancelKeyboardMacroReportMessage, + HID_RPC_VERSION, + HandshakeMessage, + KeyboardMacroStep, + KeyboardMacroReportMessage, + KeyboardReportMessage, + KeypressKeepAliveMessage, + KeypressReportMessage, + MouseReportMessage, + PointerReportMessage, + RpcMessage, + unmarshalHidRpcMessage, +} from "./hidRpc"; + +const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage(); + +interface sendMessageParams { + ignoreHandshakeState?: boolean; + useUnreliableChannel?: boolean; + requireOrdered?: boolean; +} + +export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { + const { + rpcHidChannel, + rpcHidUnreliableChannel, + rpcHidUnreliableNonOrderedChannel, + setRpcHidProtocolVersion, + rpcHidProtocolVersion, hidRpcDisabled, + } = useRTCStore(); + + const rpcHidReady = useMemo(() => { + if (hidRpcDisabled) return false; + return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null; + }, [rpcHidChannel, rpcHidProtocolVersion, hidRpcDisabled]); + + const rpcHidUnreliableReady = useMemo(() => { + return ( + rpcHidUnreliableChannel?.readyState === "open" && rpcHidProtocolVersion !== null + ); + }, [rpcHidProtocolVersion, rpcHidUnreliableChannel?.readyState]); + + const rpcHidUnreliableNonOrderedReady = useMemo(() => { + return ( + rpcHidUnreliableNonOrderedChannel?.readyState === "open" && + rpcHidProtocolVersion !== null + ); + }, [rpcHidProtocolVersion, rpcHidUnreliableNonOrderedChannel?.readyState]); + + const rpcHidStatus = useMemo(() => { + if (hidRpcDisabled) return "disabled"; + + if (!rpcHidChannel) return "N/A"; + if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState; + if (!rpcHidProtocolVersion) return "handshaking"; + return `ready (v${rpcHidProtocolVersion}${rpcHidUnreliableReady ? "+u" : ""})`; + }, [rpcHidChannel, rpcHidProtocolVersion, rpcHidUnreliableReady, hidRpcDisabled]); + + const sendMessage = useCallback( + ( + message: RpcMessage, + { + ignoreHandshakeState, + useUnreliableChannel, + requireOrdered = true, + }: sendMessageParams = {}, + ) => { + if (hidRpcDisabled) return; + 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; + + if (useUnreliableChannel) { + if (requireOrdered && rpcHidUnreliableReady) { + rpcHidUnreliableChannel?.send(data as unknown as ArrayBuffer); + } else if (!requireOrdered && rpcHidUnreliableNonOrderedReady) { + rpcHidUnreliableNonOrderedChannel?.send(data as unknown as ArrayBuffer); + } + return; + } + + rpcHidChannel?.send(data as unknown as ArrayBuffer); + }, + [ + rpcHidChannel, + rpcHidUnreliableChannel, + hidRpcDisabled, rpcHidUnreliableNonOrderedChannel, + rpcHidReady, + rpcHidUnreliableReady, + rpcHidUnreliableNonOrderedReady, + ], + ); + + 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), { + useUnreliableChannel: true, + }); + }, + [sendMessage], + ); + + const reportRelMouseEvent = useCallback( + (dx: number, dy: number, buttons: number) => { + sendMessage(new MouseReportMessage(dx, dy, buttons)); + }, + [sendMessage], + ); + + const reportKeyboardMacroEvent = useCallback( + (steps: KeyboardMacroStep[]) => { + sendMessage(new KeyboardMacroReportMessage(false, steps.length, steps)); + }, + [sendMessage], + ); + + const cancelOngoingKeyboardMacro = useCallback( + () => { + sendMessage(new CancelKeyboardMacroReportMessage()); + }, + [sendMessage], + ); + + const reportKeypressKeepAlive = useCallback(() => { + sendMessage(KEEPALIVE_MESSAGE); + }, [sendMessage]); + + const sendHandshake = useCallback(() => { + if (hidRpcDisabled) return; + if (rpcHidProtocolVersion) return; + if (!rpcHidChannel) return; + + sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true }); + }, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]); + + const handleHandshake = useCallback( + (message: HandshakeMessage) => { + if (hidRpcDisabled) return; + + 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, hidRpcDisabled], + ); + + useEffect(() => { + if (!rpcHidChannel) return; + if (hidRpcDisabled) 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); + }; + + const openHandler = () => { + console.info("HID RPC channel opened"); + sendHandshake(); + }; + + const closeHandler = () => { + console.info("HID RPC channel closed"); + setRpcHidProtocolVersion(null); + }; + + rpcHidChannel.addEventListener("message", messageHandler); + rpcHidChannel.addEventListener("close", closeHandler); + rpcHidChannel.addEventListener("open", openHandler); + + return () => { + rpcHidChannel.removeEventListener("message", messageHandler); + rpcHidChannel.removeEventListener("close", closeHandler); + rpcHidChannel.removeEventListener("open", openHandler); + }; + }, [ + rpcHidChannel, + onHidRpcMessage, + setRpcHidProtocolVersion, + sendHandshake, + handleHandshake, + hidRpcDisabled, + ]); + + return { + reportKeyboardEvent, + reportKeypressEvent, + reportAbsMouseEvent, + reportRelMouseEvent, + reportKeyboardMacroEvent, + cancelOngoingKeyboardMacro, + reportKeypressKeepAlive, + rpcHidProtocolVersion, + rpcHidReady, + rpcHidStatus, + }; +} diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index b4fcc8ef..5c52d59c 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -29,6 +29,8 @@ export interface JsonRpcErrorResponse { export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; +export const RpcMethodNotFound = -32601; + const callbackStore = new Map void>(); let requestCounter = 0; diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 5f587b08..8d101b3b 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,13 +1,51 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; -import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; +import { + KeyboardLedStateMessage, + KeyboardMacroStateMessage, + KeyboardMacroStep, + KeysDownStateMessage, +} from "@/hooks/hidRpc"; +import { + hidErrorRollOver, + hidKeyBufferSize, + KeysDownState, + useHidStore, + useRTCStore, +} from "@/hooks/stores"; +import { useHidRpc } from "@/hooks/useHidRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; +const MACRO_RESET_KEYBOARD_STATE = { + keys: new Array(hidKeyBufferSize).fill(0), + modifier: 0, + delay: 0, +}; + +export interface MacroStep { + keys: string[] | null; + modifiers: string[] | null; + delay: number; +} + +export type MacroSteps = MacroStep[]; + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); + export default function useKeyboard() { const { send } = useJsonRpc(); const { rpcDataChannel } = useRTCStore(); - const { keysDownState, setKeysDownState } = useHidStore(); + const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = + useHidStore(); + + const abortController = useRef(null); + const setAbortController = useCallback((ac: AbortController | null) => { + abortController.current = ac; + }, []); + + // Keepalive timer management + const keepAliveTimerRef = useRef(null); // 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 @@ -15,117 +53,100 @@ export default function useKeyboard() { // 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 + // 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. - const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore(); - // 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( - async (state: KeysDownState) => { - if (rpcDataChannel?.readyState !== "open") return; + // HidRPC is a binary format for exchanging keyboard and mouse events + const { + reportKeyboardEvent: sendKeyboardEventHidRpc, + reportKeypressEvent: sendKeypressEventHidRpc, + reportKeyboardMacroEvent: sendKeyboardMacroEventHidRpc, + cancelOngoingKeyboardMacro: cancelOngoingKeyboardMacroHidRpc, + reportKeypressKeepAlive: sendKeypressKeepAliveHidRpc, + rpcHidReady, + } = useHidRpc(message => { + switch (message.constructor) { + case KeysDownStateMessage: + setKeysDownState((message as KeysDownStateMessage).keysDownState); + break; + case KeyboardLedStateMessage: + setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState); + break; + case KeyboardMacroStateMessage: + if (!(message as KeyboardMacroStateMessage).isPaste) break; + setPasteModeEnabled((message as KeyboardMacroStateMessage).state); + break; + default: + break; + } + }); - console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`); - send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => { + const handleLegacyKeyboardReport = useCallback( + async (keys: number[], modifier: number) => { + send("keyboardReport", { keys, modifier }, (resp: JsonRpcResponse) => { if ("error" in resp) { - console.error(`Failed to send keyboard report ${state}`, resp.error); - } else { - // If the device supports keyPressReport API, it will (also) return the keysDownState when we send - // the keyboardReport - const keysDownState = resp.result as KeysDownState; - - if (keysDownState) { - setKeysDownState(keysDownState); // treat the response as the canonical state - setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport - } else { - // older devices versions do not return the keyDownState - // so we just pretend they accepted what we sent - setKeysDownState(state); - setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport - } + console.error(`Failed to send keyboard report ${keys} ${modifier}`, resp.error); } + + // On older backends, we need to set the keysDownState manually since without the hidRpc API, the state doesn't trickle down from the backend + setKeysDownState({ modifier, keys }); }); }, - [rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable], + [send, setKeysDownState], ); - // 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 will not understand this request and will respond with - // an error with code -32601, which means that the RPC method name was not recognized. - // In that case we will switch to local key handling and update the keysDownState - // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. - const sendKeypressEvent = useCallback( - async (key: number, press: boolean) => { - if (rpcDataChannel?.readyState !== "open") return; + const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => { + return await new Promise((resolve, reject) => { + const abortListener = () => { + reject(new Error("Keyboard report aborted")); + }; - console.debug(`Send keypressEvent key: ${key}, press: ${press}`); - send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - // -32601 means the method is not supported because the device is running an older version - if (resp.error.code === -32601) { - console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error); - setkeyPressReportApiAvailable(false); - } else { - console.error(`Failed to send key ${key} press: ${press}`, resp.error); - } - } else { - const keysDownState = resp.result as KeysDownState; + ac?.signal?.addEventListener("abort", abortListener); - if (keysDownState) { - setKeysDownState(keysDownState); - // we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here - } - } - }); - }, - [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], - ); + send( + "keyboardReport", + { keys, modifier }, + params => { + if ("error" in params) return reject(params.error); + resolve(); + }, + ); + }); + }, [send]); + + const KEEPALIVE_INTERVAL = 50; + + const cancelKeepAlive = useCallback(() => { + if (keepAliveTimerRef.current) { + clearInterval(keepAliveTimerRef.current); + keepAliveTimerRef.current = null; + } + }, []); + + const scheduleKeepAlive = useCallback(() => { + // Clears existing keepalive timer + cancelKeepAlive(); + + keepAliveTimerRef.current = setInterval(() => { + sendKeypressKeepAliveHidRpc(); + }, KEEPALIVE_INTERVAL); + }, [cancelKeepAlive, sendKeypressKeepAliveHidRpc]); // 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 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 || modifierMask > 0) { - sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); - await new Promise(resolve => setTimeout(resolve, step.delay || 50)); - - resetKeyboardState(); - } else { - // This is a delay-only step, just wait for the delay amount - await new Promise(resolve => setTimeout(resolve, step.delay || 50)); - } - - // Add a small pause between steps if not the last step - if (index < steps.length - 1) { - await new Promise(resolve => setTimeout(resolve, 10)); - } + // This is useful for macros, in case of client-side rollover, and when the browser loses focus + const resetKeyboardState = useCallback(async () => { + // Cancel keepalive since we're resetting the keyboard state + cancelKeepAlive(); + // Reset the keys buffer to zeros and the modifier state to zero + const { keys, modifier } = MACRO_RESET_KEYBOARD_STATE; + if (rpcHidReady) { + sendKeyboardEventHidRpc(keys, modifier); + } else { + // Older backends don't support the hidRpc API, so we send the full reset state + handleLegacyKeyboardReport(keys, modifier); } - }; + }, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]); // handleKeyPress is used to handle a key press or release event. // This function handle both key press and key release events. @@ -133,18 +154,44 @@ export default function useKeyboard() { // 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 sendKeypress = useCallback( + (key: number, press: boolean) => { + cancelKeepAlive(); + + sendKeypressEventHidRpc(key, press); + + if (press) { + scheduleKeepAlive(); + } + }, + [sendKeypressEventHidRpc, scheduleKeepAlive, cancelKeepAlive], + ); + const handleKeyPress = useCallback( async (key: number, press: boolean) => { - if (rpcDataChannel?.readyState !== "open") return; + if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return; if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings) - if (keyPressReportApiAvailable) { + if (rpcHidReady) { // if the keyPress api is available, we can just send the key press event - sendKeypressEvent(key, press); + // 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. + sendKeypress(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 + // Older backends don't support the hidRpc API, so we need: + // 1. Calculate the state + // 2. Send the newly calculated state to the device + const downState = simulateDeviceSideKeyHandlingForLegacyDevices( + keysDownState, + key, + press, + ); + + handleLegacyKeyboardReport(downState.keys, downState.modifier); // if we just sent ErrorRollOver, reset to empty state if (downState.keys[0] === hidErrorRollOver) { @@ -152,11 +199,22 @@ export default function useKeyboard() { } } }, - [keyPressReportApiAvailable, keysDownState, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent], + [ + rpcDataChannel?.readyState, + rpcHidReady, + keysDownState, + handleLegacyKeyboardReport, + resetKeyboardState, + sendKeypress, + ], ); // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists - function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState { + function simulateDeviceSideKeyHandlingForLegacyDevices( + state: KeysDownState, + key: number, + press: boolean, + ): KeysDownState { // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver // for handling key presses and releases. It ensures that the USB gadget // behaves similarly to a real USB HID keyboard. This logic is paralleled @@ -168,7 +226,7 @@ export default function useKeyboard() { 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 + // This allows us to track the state of dynamic modifier keys like // Shift, Control, Alt, and Super. if (press) { modifiers |= modifierMask; @@ -185,7 +243,7 @@ export default function useKeyboard() { // 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 + 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) { @@ -207,12 +265,113 @@ export default function useKeyboard() { 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`) + console.debug(`key ${key} not found in buffer, nothing to release`); } } } return { modifier: modifiers, keys }; } - return { handleKeyPress, resetKeyboardState, executeMacro }; + // Cleanup function to cancel keepalive timer + const cleanup = useCallback(() => { + cancelKeepAlive(); + }, [cancelKeepAlive]); + + + // 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 executeMacroRemote = useCallback(async ( + steps: MacroSteps, + ) => { + const macro: KeyboardMacroStep[] = []; + + for (const [_, step] of steps.entries()) { + 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 || modifierMask > 0) { + macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); + macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); + } + } + + sendKeyboardMacroEventHidRpc(macro); + }, [sendKeyboardMacroEventHidRpc]); + const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { + const promises: (() => Promise)[] = []; + + const ac = new AbortController(); + setAbortController(ac); + + for (const [_, step] of steps.entries()) { + 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 || modifierMask > 0) { + promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); + promises.push(() => resetKeyboardState()); + promises.push(() => sleep(step.delay || 100)); + } + } + + const runAll = async () => { + for (const promise of promises) { + // Check if we've been aborted before executing each promise + if (ac.signal.aborted) { + throw new Error("Macro execution aborted"); + } + await promise(); + } + } + + return await new Promise((resolve, reject) => { + // Set up abort listener + const abortListener = () => { + reject(new Error("Macro execution aborted")); + }; + + ac.signal.addEventListener("abort", abortListener); + + runAll() + .then(() => { + ac.signal.removeEventListener("abort", abortListener); + resolve(); + }) + .catch((error) => { + ac.signal.removeEventListener("abort", abortListener); + reject(error); + }); + }); + }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); + const executeMacro = useCallback(async (steps: MacroSteps) => { + if (rpcHidReady) { + return executeMacroRemote(steps); + } + return executeMacroClientSide(steps); + }, [rpcHidReady, executeMacroRemote, executeMacroClientSide]); + + const cancelExecuteMacro = useCallback(async () => { + if (abortController.current) { + abortController.current.abort(); + } + if (!rpcHidReady) return; + // older versions don't support this API, + // and all paste actions are pure-frontend, + // we don't need to cancel it actually + cancelOngoingKeyboardMacroHidRpc(); + }, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]); + + return { handleKeyPress, resetKeyboardState, executeMacro, cleanup, cancelExecuteMacro }; } diff --git a/ui/src/hooks/useMouse.ts b/ui/src/hooks/useMouse.ts new file mode 100644 index 00000000..a814bca0 --- /dev/null +++ b/ui/src/hooks/useMouse.ts @@ -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, + }; +} \ No newline at end of file diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx new file mode 100644 index 00000000..7341dacb --- /dev/null +++ b/ui/src/hooks/useVersion.tsx @@ -0,0 +1,79 @@ +import { useCallback } from "react"; + +import { useDeviceStore } from "@/hooks/stores"; +import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; + +export interface VersionInfo { + appVersion: string; + systemVersion: string; +} + +export interface SystemVersionInfo { + local: VersionInfo; + remote?: VersionInfo; + systemUpdateAvailable: boolean; + appUpdateAvailable: boolean; + error?: string; +} + +export function useVersion() { + const { + appVersion, + systemVersion, + setAppVersion, + setSystemVersion, + } = useDeviceStore(); + const { send } = useJsonRpc(); + const getVersionInfo = useCallback(() => { + return new Promise((resolve, reject) => { + send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to check for updates: ${resp.error}`); + reject(new Error("Failed to check for updates")); + } else { + const result = resp.result as SystemVersionInfo; + setAppVersion(result.local.appVersion); + setSystemVersion(result.local.systemVersion); + + if (result.error) { + notifications.error(`Failed to check for updates: ${result.error}`); + reject(new Error("Failed to check for updates")); + } else { + resolve(result); + } + } + }); + }); + }, [send, setAppVersion, setSystemVersion]); + + const getLocalVersion = useCallback(() => { + return new Promise((resolve, reject) => { + send("getLocalVersion", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.log(resp.error) + if (resp.error.code === RpcMethodNotFound) { + console.warn("Failed to get device version, using legacy version"); + return getVersionInfo().then(result => resolve(result.local)).catch(reject); + } + console.error("Failed to get device version N", resp.error); + notifications.error(`Failed to get device version: ${resp.error}`); + reject(new Error("Failed to get device version")); + } else { + const result = resp.result as VersionInfo; + + setAppVersion(result.appVersion); + setSystemVersion(result.systemVersion); + resolve(result); + } + }); + }); + }, [send, setAppVersion, setSystemVersion, getVersionInfo]); + + return { + getVersionInfo, + getLocalVersion, + appVersion, + systemVersion, + }; +} \ No newline at end of file diff --git a/ui/src/index.css b/ui/src/index.css index db03b427..6eaae1f7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -239,7 +239,7 @@ video::-webkit-media-controls { } .simple-keyboard-arrows .hg-button { - @apply flex w-[50px] grow-0 items-center justify-center; + @apply flex w-[50px] items-center justify-center; } .controlArrows { @@ -264,7 +264,7 @@ video::-webkit-media-controls { } .simple-keyboard-control .hg-button { - @apply flex w-[50px] grow-0 items-center justify-center; + @apply flex w-[50px] items-center justify-center; } .numPad { @@ -325,6 +325,20 @@ video::-webkit-media-controls { @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; diff --git a/ui/src/keyboardLayouts/en_US.ts b/ui/src/keyboardLayouts/en_US.ts index 872d3569..2076ce64 100644 --- a/ui/src/keyboardLayouts/en_US.ts +++ b/ui/src/keyboardLayouts/en_US.ts @@ -144,33 +144,33 @@ export const keyDisplayMap: Record = { AltMetaEscape: "Alt + Meta + Escape", CtrlAltBackspace: "Ctrl + Alt + Backspace", AltGr: "AltGr", - AltLeft: "Alt", - AltRight: "Alt", + AltLeft: "Alt ⌥", + AltRight: "⌥ Alt", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", ArrowUp: "↑", Backspace: "Backspace", "(Backspace)": "Backspace", - CapsLock: "Caps Lock", + CapsLock: "Caps Lock ⇪", Clear: "Clear", - ControlLeft: "Ctrl", - ControlRight: "Ctrl", - Delete: "Delete", + ControlLeft: "Ctrl ⌃", + ControlRight: "⌃ Ctrl", + Delete: "Delete ⌦", End: "End", Enter: "Enter", Escape: "Esc", Home: "Home", Insert: "Insert", Menu: "Menu", - MetaLeft: "Meta", - MetaRight: "Meta", + MetaLeft: "Meta ⌘", + MetaRight: "⌘ Meta", PageDown: "PgDn", PageUp: "PgUp", - ShiftLeft: "Shift", - ShiftRight: "Shift", + ShiftLeft: "Shift ⇧", + ShiftRight: "⇧ Shift", Space: " ", - Tab: "Tab", + Tab: "Tab ⇥", // Letters KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e", diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index 14b0c606..1ffc8d78 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -81,12 +81,6 @@ export const keys = { Help: 0x75, Home: 0x4a, Insert: 0x49, - International1: 0x87, - International2: 0x88, - International3: 0x89, - International4: 0x8a, - International5: 0x8b, - International6: 0x8c, International7: 0x8d, International8: 0x8e, International9: 0x8f, @@ -117,14 +111,20 @@ export const keys = { KeyX: 0x1b, KeyY: 0x1c, KeyZ: 0x1d, + 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, - Lang1: 0x90, // Hangul/English toggle on Korean keyboards - Lang2: 0x91, // Hanja conversion on Korean keyboards - Lang3: 0x92, // Katakana on Japanese keyboards - Lang4: 0x93, // Hiragana on Japanese keyboards - Lang5: 0x94, // Zenkaku/Hankaku toggle on Japanese keyboards Lang6: 0x95, Lang7: 0x96, Lang8: 0x97, @@ -157,7 +157,7 @@ export const keys = { NumpadClearEntry: 0xd9, NumpadColon: 0xcb, NumpadComma: 0x85, - NumpadDecimal: 0x63, + NumpadDecimal: 0x63, // and Delete NumpadDecimalBase: 0xdc, NumpadDelete: 0x63, NumpadDivide: 0x54, @@ -211,7 +211,7 @@ export const keys = { PageUp: 0x4b, Paste: 0x7d, Pause: 0x48, - Period: 0x37, + Period: 0x37, // aka Dot Power: 0x66, PrintScreen: 0x46, Prior: 0x9d, @@ -226,7 +226,7 @@ export const keys = { Slash: 0x38, Space: 0x2c, Stop: 0x78, - SystemRequest: 0x9a, + SystemRequest: 0x9a, // aka Attention Tab: 0x2b, ThousandsSeparator: 0xb2, Tilde: 0x35, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 53746580..79ca6717 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -7,7 +7,7 @@ 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"; @@ -28,7 +28,7 @@ import DeviceIdRename from "@routes/devices.$id.rename"; import DevicesRoute from "@routes/devices"; import SettingsIndexRoute from "@routes/devices.$id.settings._index"; import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index"; -const Notifications = lazy(() => import("@/notifications")); +import Notifications from "@/notifications"; const SignupRoute = lazy(() => import("@routes/signup")); const LoginRoute = lazy(() => import("@routes/login")); const DevicesAlreadyAdopted = lazy(() => import("@routes/devices.already-adopted")); @@ -116,6 +116,7 @@ if (isOnDevice) { path: "/", errorElement: , element: , + HydrateFallback: () =>
Loading...
, loader: DeviceRoute.loader, children: [ { diff --git a/ui/src/root.tsx b/ui/src/root.tsx index 94130555..8995fd96 100644 --- a/ui/src/root.tsx +++ b/ui/src/root.tsx @@ -1,4 +1,4 @@ -import { Outlet } from "react-router-dom"; +import { Outlet } from "react-router"; function Root() { return ; diff --git a/ui/src/routes/adopt.tsx b/ui/src/routes/adopt.tsx index 8b8325bf..b7079f5c 100644 --- a/ui/src/routes/adopt.tsx +++ b/ui/src/routes/adopt.tsx @@ -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; diff --git a/ui/src/routes/devices.$id.deregister.tsx b/ui/src/routes/devices.$id.deregister.tsx index 8c0a87fe..e5dd2a35 100644 --- a/ui/src/routes/devices.$id.deregister.tsx +++ b/ui/src/routes/devices.$id.deregister.tsx @@ -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; diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 68979bef..bc29c455 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -7,7 +7,7 @@ import { } 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"; @@ -355,7 +355,7 @@ function UrlView({ const popularImages = [ { name: "Ubuntu 24.04 LTS", - url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso", + url: "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-desktop-amd64.iso", icon: UbuntuIcon, }, { @@ -369,8 +369,8 @@ function UrlView({ icon: DebianIcon, }, { - name: "Fedora 41", - url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", + name: "Fedora 42", + url: "https://download.fedoraproject.org/pub/fedora/linux/releases/42/Workstation/x86_64/iso/Fedora-Workstation-Live-42-1.1.x86_64.iso", icon: FedoraIcon, }, { @@ -385,7 +385,7 @@ function UrlView({ }, { name: "Arch Linux", - url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", + url: "https://archlinux.doridian.net/iso/latest/archlinux-x86_64.iso", icon: ArchIcon, }, { diff --git a/ui/src/routes/devices.$id.other-session.tsx b/ui/src/routes/devices.$id.other-session.tsx index 16cb4799..8a767d51 100644 --- a/ui/src/routes/devices.$id.other-session.tsx +++ b/ui/src/routes/devices.$id.other-session.tsx @@ -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"; diff --git a/ui/src/routes/devices.$id.rename.tsx b/ui/src/routes/devices.$id.rename.tsx index 28525610..39f06bcf 100644 --- a/ui/src/routes/devices.$id.rename.tsx +++ b/ui/src/routes/devices.$id.rename.tsx @@ -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; diff --git a/ui/src/routes/devices.$id.settings._index.tsx b/ui/src/routes/devices.$id.settings._index.tsx index 4fb35eda..e4a9d7f1 100644 --- a/ui/src/routes/devices.$id.settings._index.tsx +++ b/ui/src/routes/devices.$id.settings._index.tsx @@ -1,8 +1,9 @@ -import { LoaderFunctionArgs, redirect } from "react-router-dom"; +import { redirect } from "react-router"; +import type { LoaderFunction, LoaderFunctionArgs } from "react-router"; import { getDeviceUiPath } from "../hooks/useAppNavigation"; -const loader = ({ params }: LoaderFunctionArgs) => { +const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { return redirect(getDeviceUiPath("/settings/general", params.id)); } diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 31b7bb79..b5ccca07 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -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`) diff --git a/ui/src/routes/devices.$id.settings.access.local-auth.tsx b/ui/src/routes/devices.$id.settings.access.local-auth.tsx index 50b2cc4a..5f5231d3 100644 --- a/ui/src/routes/devices.$id.settings.access.local-auth.tsx +++ b/ui/src/routes/devices.$id.settings.access.local-auth.tsx @@ -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"; diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index 0bf114cd..db0e0530 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { useCallback } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index b719d7e6..38c15412 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -1,14 +1,14 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { CheckCircleIcon } from "@heroicons/react/20/solid"; import Card from "@/components/Card"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; -import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores"; -import notifications from "@/notifications"; +import { UpdateState, useUpdateStore } from "@/hooks/stores"; import LoadingSpinner from "@/components/LoadingSpinner"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; +import { SystemVersionInfo, useVersion } from "@/hooks/useVersion"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); @@ -41,13 +41,7 @@ export default function SettingsGeneralUpdateRoute() { return navigate("..")} onConfirmUpdate={onConfirmUpdate} />; } -export interface SystemVersionInfo { - local: { appVersion: string; systemVersion: string }; - remote?: { appVersion: string; systemVersion: string }; - systemUpdateAvailable: boolean; - appUpdateAvailable: boolean; - error?: string; -} + export function Dialog({ onClose, @@ -134,30 +128,8 @@ function LoadingState({ }) { const [progressWidth, setProgressWidth] = useState("0%"); const abortControllerRef = useRef(null); - const { setAppVersion, setSystemVersion } = useDeviceStore(); - const { send } = useJsonRpc(); - const getVersionInfo = useCallback(() => { - return new Promise((resolve, reject) => { - send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(`Failed to check for updates: ${resp.error}`); - reject(new Error("Failed to check for updates")); - } else { - const result = resp.result as SystemVersionInfo; - setAppVersion(result.local.appVersion); - setSystemVersion(result.local.systemVersion); - - if (result.error) { - notifications.error(`Failed to check for updates: ${result.error}`); - reject(new Error("Failed to check for updates")); - } else { - resolve(result); - } - } - }); - }); - }, [send, setAppVersion, setSystemVersion]); + const { getVersionInfo } = useVersion(); const progressBarRef = useRef(null); useEffect(() => { diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index abd72bf7..6f5c2e86 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -53,7 +53,7 @@ export default function SettingsKeyboardRoute() {

- Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system. + The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.

diff --git a/ui/src/routes/devices.$id.settings.macros.add.tsx b/ui/src/routes/devices.$id.settings.macros.add.tsx index 1b3ce303..7a9f493e 100644 --- a/ui/src/routes/devices.$id.settings.macros.add.tsx +++ b/ui/src/routes/devices.$id.settings.macros.add.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { useState } from "react"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; diff --git a/ui/src/routes/devices.$id.settings.macros.edit.tsx b/ui/src/routes/devices.$id.settings.macros.edit.tsx index 336fe858..131fa1cf 100644 --- a/ui/src/routes/devices.$id.settings.macros.edit.tsx +++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { useState, useEffect } from "react"; import { LuTrash2 } from "react-icons/lu"; diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 734f17e6..94fded36 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,5 +1,5 @@ import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { LuPenLine, LuCopy, diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index f2b169d9..76b0ae27 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -90,6 +90,7 @@ export default function SettingsMouseRoute() { send("getJigglerState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; const isEnabled = resp.result as boolean; + console.log("Jiggler is enabled:", isEnabled); // If the jiggler is disabled, set the selected option to "disabled" and nothing else if (!isEnabled) return setSelectedJigglerOption("disabled"); diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index d87eb2be..d1ac6966 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -166,11 +166,11 @@ export default function SettingsNetworkRoute() { }, [getNetworkState, getNetworkSettings]); const handleIpv4ModeChange = (value: IPv4Mode | string) => { - setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode }); + setNetworkSettingsRemote({ ...networkSettings, ipv4_mode: value as IPv4Mode }); }; const handleIpv6ModeChange = (value: IPv6Mode | string) => { - setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode }); + setNetworkSettingsRemote({ ...networkSettings, ipv6_mode: value as IPv6Mode }); }; const handleLldpModeChange = (value: LLDPMode | string) => { @@ -419,7 +419,7 @@ export default function SettingsNetworkRoute() { value={networkSettings.ipv6_mode} onChange={e => handleIpv6ModeChange(e.target.value)} options={filterUnknown([ - // { value: "disabled", label: "Disabled" }, + { value: "disabled", label: "Disabled" }, { value: "slaac", label: "SLAAC" }, // { value: "dhcpv6", label: "DHCPv6" }, // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 5a617782..49f26366 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -1,4 +1,4 @@ -import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { NavLink, Outlet, useLocation } from "react-router"; import { LuSettings, LuMouse, diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index e5072d69..ea1a101a 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -1,15 +1,16 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/Button"; import { TextAreaWithLabel } from "@/components/TextArea"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { useSettingsStore } from "@/hooks/stores"; - -import notifications from "../notifications"; -import { SelectMenuBasic } from "../components/SelectMenuBasic"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import Fieldset from "@components/Fieldset"; +import notifications from "@/notifications"; import { SettingsItem } from "./devices.$id.settings"; + const defaultEdid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; const edids = [ @@ -50,21 +51,27 @@ export default function SettingsVideoRoute() { const [streamQuality, setStreamQuality] = useState("1"); const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); + const [edidLoading, setEdidLoading] = useState(false); // Video enhancement settings from store const { - videoSaturation, setVideoSaturation, - videoBrightness, setVideoBrightness, - videoContrast, setVideoContrast + videoSaturation, + setVideoSaturation, + videoBrightness, + setVideoBrightness, + videoContrast, + setVideoContrast, } = useSettingsStore(); useEffect(() => { + setEdidLoading(true); send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; setStreamQuality(String(resp.result)); }); send("getEDID", {}, (resp: JsonRpcResponse) => { + setEdidLoading(false); if ("error" in resp) { notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); return; @@ -89,28 +96,36 @@ export default function SettingsVideoRoute() { }, [send]); const handleStreamQualityChange = (factor: string) => { - send("setStreamQualityFactor", { factor: Number(factor) }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, - ); - return; - } + send( + "setStreamQualityFactor", + { factor: Number(factor) }, + (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, + ); + return; + } - notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`); - setStreamQuality(factor); - }); + notifications.success( + `Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`, + ); + setStreamQuality(factor); + }, + ); }; const handleEDIDChange = (newEdid: string) => { + setEdidLoading(true); send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => { + setEdidLoading(false); if ("error" in resp) { notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`); return; } notifications.success( - `EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`, + `EDID set successfully to ${edids.find(x => x.value === newEdid)?.label ?? "the custom EDID"}`, ); // Update the EDID value in the UI setEdid(newEdid); @@ -158,7 +173,7 @@ export default function SettingsVideoRoute() { step="0.1" value={videoSaturation} onChange={e => setVideoSaturation(parseFloat(e.target.value))} - className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700" /> @@ -173,7 +188,7 @@ export default function SettingsVideoRoute() { step="0.1" value={videoBrightness} onChange={e => setVideoBrightness(parseFloat(e.target.value))} - className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700" /> @@ -188,7 +203,7 @@ export default function SettingsVideoRoute() { step="0.1" value={videoContrast} onChange={e => setVideoContrast(parseFloat(e.target.value))} - className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700" /> @@ -205,60 +220,64 @@ export default function SettingsVideoRoute() { />
- - - { - if (e.target.value === "custom") { - setEdid("custom"); - setCustomEdidValue(""); - } else { - setCustomEdidValue(null); - handleEDIDChange(e.target.value as string); - } - }} - options={[...edids, { value: "custom", label: "Custom" }]} - /> - - {customEdidValue !== null && ( - <> - - setCustomEdidValue(e.target.value)} - /> -
-
- - )} + setCustomEdidValue(e.target.value)} + /> +
+
+ + )} +
diff --git a/ui/src/routes/devices.$id.setup.tsx b/ui/src/routes/devices.$id.setup.tsx index 1c477d6e..2fd65f50 100644 --- a/ui/src/routes/devices.$id.setup.tsx +++ b/ui/src/routes/devices.$id.setup.tsx @@ -1,12 +1,5 @@ -import { - ActionFunctionArgs, - Form, - LoaderFunctionArgs, - redirect, - useActionData, - useParams, - useSearchParams, -} from "react-router-dom"; +import { Form, redirect, useActionData, useParams, useSearchParams } from "react-router"; +import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router"; import SimpleNavbar from "@components/SimpleNavbar"; import GridBackground from "@components/GridBackground"; @@ -20,7 +13,7 @@ import { CLOUD_API } from "@/ui.config"; import api from "../api"; -const loader = async ({ params }: LoaderFunctionArgs) => { +const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { await checkAuth(); const res = await fetch(`${CLOUD_API}/devices/${params.id}`, { method: "GET", @@ -35,7 +28,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => { } }; -const action = async ({ request }: ActionFunctionArgs) => { +const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { // Handle form submission const { name, id, returnTo } = Object.fromEntries(await request.formData()); const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name }); @@ -43,7 +36,7 @@ const action = async ({ request }: ActionFunctionArgs) => { if (res.ok) { return redirect(returnTo?.toString() ?? `/devices/${id}`); } else { - return { error: "There was an error creating your device" }; + return { error: "There was an error registering your device" }; } }; diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 9be05f60..a1ace077 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -1,8 +1,6 @@ import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - LoaderFunctionArgs, Outlet, - Params, redirect, useLoaderData, useLocation, @@ -10,7 +8,8 @@ import { useOutlet, useParams, useSearchParams, -} from "react-router-dom"; +} from "react-router"; +import type { LoaderFunction, LoaderFunctionArgs, Params } from "react-router"; import { useInterval } from "usehooks-ts"; import { FocusTrap } from "focus-trap-react"; import { motion, AnimatePresence } from "framer-motion"; @@ -20,14 +19,12 @@ import { CLOUD_API, DEVICE_API } from "@/ui.config"; import api from "@/api"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { cx } from "@/cva.config"; -import notifications from "@/notifications"; import { KeyboardLedState, KeysDownState, NetworkState, OtaState, USBStates, - useDeviceStore, useHidStore, useNetworkStateStore, User, @@ -43,7 +40,7 @@ const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectio const Terminal = lazy(() => import('@components/Terminal')); const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); import Modal from "@/components/Modal"; -import { JsonRpcRequest, JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { ConnectionFailedOverlay, LoadingConnectionOverlay, @@ -52,7 +49,7 @@ import { import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; import { DeviceStatus } from "@routes/welcome-local"; -import { SystemVersionInfo } from "@routes/devices.$id.settings.general.update"; +import { useVersion } from "@/hooks/useVersion"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; @@ -112,7 +109,7 @@ const cloudLoader = async (params: Params): Promise => return { user, iceConfig, deviceName: device.name || device.id }; }; -const loader = ({ params }: LoaderFunctionArgs) => { +const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); }; @@ -135,7 +132,10 @@ export default function KvmIdRoute() { setRpcDataChannel, isTurnServerInUse, setTurnServerInUse, rpcDataChannel, - setTransceiver + setTransceiver, + setRpcHidChannel, + setRpcHidUnreliableNonOrderedChannel, + setRpcHidUnreliableChannel, } = useRTCStore(); const location = useLocation(); @@ -482,6 +482,30 @@ export default function KvmIdRoute() { setRpcDataChannel(rpcDataChannel); }; + const rpcHidChannel = pc.createDataChannel("hidrpc"); + rpcHidChannel.binaryType = "arraybuffer"; + rpcHidChannel.onopen = () => { + setRpcHidChannel(rpcHidChannel); + }; + + const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", { + ordered: true, + maxRetransmits: 0, + }); + rpcHidUnreliableChannel.binaryType = "arraybuffer"; + rpcHidUnreliableChannel.onopen = () => { + setRpcHidUnreliableChannel(rpcHidUnreliableChannel); + }; + + const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", { + ordered: false, + maxRetransmits: 0, + }); + rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer"; + rpcHidUnreliableNonOrderedChannel.onopen = () => { + setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel); + }; + setPeerConnection(pc); }, [ cleanupAndStopReconnecting, @@ -492,6 +516,9 @@ export default function KvmIdRoute() { setPeerConnection, setPeerConnectionState, setRpcDataChannel, + setRpcHidChannel, + setRpcHidUnreliableNonOrderedChannel, + setRpcHidUnreliableChannel, setTransceiver, ]); @@ -575,8 +602,8 @@ export default function KvmIdRoute() { const { keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, - setkeyPressReportApiAvailable } = useHidStore(); + const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -613,7 +640,6 @@ export default function KvmIdRoute() { const downState = resp.params as KeysDownState; console.debug("Setting key down state:", downState); setKeysDownState(downState); - setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } if (resp.method === "otaState") { @@ -687,10 +713,10 @@ export default function KvmIdRoute() { send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { // -32601 means the method is not supported - if (resp.error.code === -32601) { + if (resp.error.code === RpcMethodNotFound) { // if we don't support key down state, we know key press is also not available console.warn("Failed to get key down state, switching to old-school", resp.error); - setkeyPressReportApiAvailable(false); + setHidRpcDisabled(true); } else { console.error("Failed to get key down state", resp.error); } @@ -698,11 +724,10 @@ export default function KvmIdRoute() { const downState = resp.result as KeysDownState; console.debug("Keyboard key down state", downState); setKeysDownState(downState); - setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } setNeedKeyDownState(false); }); - }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState]); + }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { @@ -731,26 +756,13 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const { appVersion, setAppVersion, setSystemVersion} = useDeviceStore(); + const { appVersion, getLocalVersion} = useVersion(); useEffect(() => { if (appVersion) return; - send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(`Failed to get device version: ${resp.error}`); - return - } - - const result = resp.result as SystemVersionInfo; - if (result.error) { - notifications.error(`Failed to get device version: ${result.error}`); - } - - setAppVersion(result.local.appVersion); - setSystemVersion(result.local.systemVersion); - }); - }, [appVersion, send, setAppVersion, setSystemVersion]); + getLocalVersion(); + }, [appVersion, getLocalVersion]); const ConnectionStatusElement = useMemo(() => { const hasConnectionFailed = diff --git a/ui/src/routes/devices.tsx b/ui/src/routes/devices.tsx index b6af0f06..1dac696d 100644 --- a/ui/src/routes/devices.tsx +++ b/ui/src/routes/devices.tsx @@ -1,4 +1,5 @@ -import { useLoaderData, useRevalidator } from "react-router-dom"; +import { useLoaderData, useRevalidator } from "react-router"; +import type { LoaderFunction } from "react-router"; import { LuMonitorSmartphone } from "react-icons/lu"; import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { useInterval } from "usehooks-ts"; @@ -16,7 +17,7 @@ interface LoaderData { user: User; } -const loader = async () => { +const loader: LoaderFunction = async () => { const user = await checkAuth(); try { diff --git a/ui/src/routes/login-local.tsx b/ui/src/routes/login-local.tsx index 9258639b..5fab7e6e 100644 --- a/ui/src/routes/login-local.tsx +++ b/ui/src/routes/login-local.tsx @@ -1,4 +1,5 @@ -import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom"; +import { Form, redirect, useActionData } from "react-router"; +import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import { useState } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -17,7 +18,7 @@ import ExtLink from "../components/ExtLink"; import { DeviceStatus } from "./welcome-local"; -const loader = async () => { +const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); @@ -29,7 +30,7 @@ const loader = async () => { return null; }; -const action = async ({ request }: ActionFunctionArgs) => { +const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const password = formData.get("password"); @@ -86,6 +87,7 @@ export default function LoginLocalRoute() { label="Password" type={showPassword ? "text" : "password"} name="password" + autoComplete="current-password" placeholder="Enter your password" autoFocus error={actionData?.error} diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index e2347a7f..fb0f0c45 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -1,4 +1,4 @@ -import { useLocation, useSearchParams } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router"; import AuthLayout from "@components/AuthLayout"; diff --git a/ui/src/routes/signup.tsx b/ui/src/routes/signup.tsx index af064009..c6efbcbf 100644 --- a/ui/src/routes/signup.tsx +++ b/ui/src/routes/signup.tsx @@ -1,4 +1,4 @@ -import { useLocation, useSearchParams } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router"; import AuthLayout from "@components/AuthLayout"; diff --git a/ui/src/routes/welcome-local.mode.tsx b/ui/src/routes/welcome-local.mode.tsx index 06ca62a4..8d1a808b 100644 --- a/ui/src/routes/welcome-local.mode.tsx +++ b/ui/src/routes/welcome-local.mode.tsx @@ -1,4 +1,5 @@ -import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom"; +import { Form, redirect, useActionData } from "react-router"; +import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import { useState } from "react"; import GridBackground from "@components/GridBackground"; @@ -14,7 +15,7 @@ import api from "../api"; import { DeviceStatus } from "./welcome-local"; -const loader = async () => { +const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); @@ -23,7 +24,7 @@ const loader = async () => { return null; }; -const action = async ({ request }: ActionFunctionArgs) => { +const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const localAuthMode = formData.get("localAuthMode"); if (!localAuthMode) return { error: "Please select an authentication mode" }; @@ -162,5 +163,5 @@ export default function WelcomeLocalModeRoute() { ); } -WelcomeLocalModeRoute.action = action; WelcomeLocalModeRoute.loader = loader; +WelcomeLocalModeRoute.action = action; diff --git a/ui/src/routes/welcome-local.password.tsx b/ui/src/routes/welcome-local.password.tsx index 4b2c05d7..d0b7c7a9 100644 --- a/ui/src/routes/welcome-local.password.tsx +++ b/ui/src/routes/welcome-local.password.tsx @@ -1,4 +1,5 @@ -import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom"; +import { Form, redirect, useActionData } from "react-router"; +import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import { useState, useRef, useEffect } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -15,7 +16,7 @@ import api from "../api"; import { DeviceStatus } from "./welcome-local"; -const loader = async () => { +const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); @@ -24,7 +25,7 @@ const loader = async () => { return null; }; -const action = async ({ request }: ActionFunctionArgs) => { +const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const password = formData.get("password"); const confirmPassword = formData.get("confirmPassword"); @@ -174,5 +175,5 @@ export default function WelcomeLocalPasswordRoute() { ); } -WelcomeLocalPasswordRoute.action = action; WelcomeLocalPasswordRoute.loader = loader; +WelcomeLocalPasswordRoute.action = action; diff --git a/ui/src/routes/welcome-local.tsx b/ui/src/routes/welcome-local.tsx index c516952a..d7ff117e 100644 --- a/ui/src/routes/welcome-local.tsx +++ b/ui/src/routes/welcome-local.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { cx } from "cva"; -import { redirect } from "react-router-dom"; +import { redirect } from "react-router"; +import type { LoaderFunction } from "react-router"; import GridBackground from "@components/GridBackground"; import Container from "@components/Container"; @@ -17,7 +18,7 @@ export interface DeviceStatus { isSetup: boolean; } -const loader = async () => { +const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 99c1a504..9e361339 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -94,6 +94,17 @@ export const formatters = { }, }; +export function someIterable( + iterable: Iterable, + predicate: (item: T) => boolean, +): boolean { + for (const item of iterable) { + if (predicate(item)) return true; + } + + return false; +} + export const VIDEO = new Blob( [ new Uint8Array([ diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5871c4b2..13b2da02 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -28,20 +28,39 @@ export default defineConfig(({ mode, command }) => { return { plugins, - build: { outDir: isCloud ? "dist" : "../static" }, + esbuild: { + pure: ["console.debug"], + }, + assetsInclude: ["**/*.woff2"], + build: { + outDir: isCloud ? "dist" : "../static", + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes("node_modules")) { + return "vendor"; + } + return null; + }, + assetFileNames: "assets/immutable/[name]-[hash][extname]", + chunkFileNames: "assets/immutable/[name]-[hash].js", + entryFileNames: "assets/immutable/[name]-[hash].js", + }, + }, + }, server: { host: "0.0.0.0", https: useSSL, proxy: JETKVM_PROXY_URL ? { - "/me": JETKVM_PROXY_URL, - "/device": JETKVM_PROXY_URL, - "/webrtc": JETKVM_PROXY_URL, - "/auth": JETKVM_PROXY_URL, - "/storage": JETKVM_PROXY_URL, - "/cloud": JETKVM_PROXY_URL, - "/developer": JETKVM_PROXY_URL, - } + "/me": JETKVM_PROXY_URL, + "/device": JETKVM_PROXY_URL, + "/webrtc": JETKVM_PROXY_URL, + "/auth": JETKVM_PROXY_URL, + "/storage": JETKVM_PROXY_URL, + "/cloud": JETKVM_PROXY_URL, + "/developer": JETKVM_PROXY_URL, + } : undefined, }, base: onDevice && command === "build" ? "/static" : "/", diff --git a/usb.go b/usb.go index d29e01ad..99287a30 100644 --- a/usb.go +++ b/usb.go @@ -27,13 +27,19 @@ func initUsbGadget() { gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) { if currentSession != nil { - writeJSONRPCEvent("keyboardLedState", state, currentSession) + currentSession.reportHidRPCKeyboardLedState(state) } }) gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) { if currentSession != nil { - writeJSONRPCEvent("keysDownState", state, currentSession) + currentSession.enqueueKeysDownState(state) + } + }) + + gadget.SetOnKeepAliveReset(func() { + if currentSession != nil { + currentSession.resetKeepAliveTime() } }) @@ -43,11 +49,11 @@ func initUsbGadget() { } } -func rpcKeyboardReport(modifier byte, keys []byte) (usbgadget.KeysDownState, error) { +func rpcKeyboardReport(modifier byte, keys []byte) error { return gadget.KeyboardReport(modifier, keys) } -func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) { +func rpcKeypressReport(key byte, press bool) error { return gadget.KeypressReport(key, press) } diff --git a/web.go b/web.go index 21e17e74..45253579 100644 --- a/web.go +++ b/web.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/pprof" "path/filepath" + "slices" "strings" "time" @@ -24,6 +25,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog" + "github.com/vearutop/statigz" "golang.org/x/crypto/bcrypt" ) @@ -66,6 +68,10 @@ type SetupRequest struct { Password string `json:"password,omitempty"` } +var cachableFileExtensions = []string{ + ".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2", +} + func setupRouter() *gin.Engine { gin.SetMode(gin.ReleaseMode) gin.DisableConsoleColor() @@ -75,23 +81,47 @@ func setupRouter() *gin.Engine { return *ginLogger }), )) - staticFS, _ := fs.Sub(staticFiles, "static") + + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory") + } + staticFileServer := http.StripPrefix("/static", statigz.FileServer( + staticFS.(fs.ReadDirFS), + )) // Add a custom middleware to set cache headers for images // This is crucial for optimizing the initial welcome screen load time // By enabling caching, we ensure that pre-loaded images are stored in the browser cache // This allows for a smoother enter animation and improved user experience on the welcome screen r.Use(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/static/assets/immutable/") { + c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year + c.Next() + return + } + if strings.HasPrefix(c.Request.URL.Path, "/static/") { ext := filepath.Ext(c.Request.URL.Path) - if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" { + if slices.Contains(cachableFileExtensions, ext) { c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes } } + c.Next() }) - r.StaticFS("/static", http.FS(staticFS)) + r.GET("/robots.txt", func(c *gin.Context) { + c.Header("Content-Type", "text/plain") + c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year + c.String(http.StatusOK, "User-agent: *\nDisallow: /") + }) + + r.Any("/static/*w", func(c *gin.Context) { + staticFileServer.ServeHTTP(c.Writer, c.Request) + }) + + // Public routes (no authentication required) r.POST("/auth/login-local", handleLogin) // We use this to determine if the device is setup @@ -198,6 +228,10 @@ func handleWebRTCSession(c *gin.Context) { _ = peerConn.Close() }() } + + // Cancel any ongoing keyboard macro when session changes + cancelKeyboardMacro() + currentSession = session c.JSON(http.StatusOK, gin.H{"sd": sd}) } @@ -532,14 +566,31 @@ func RunWebServer() { r := setupRouter() // Determine the binding address based on the config - bindAddress := ":80" // Default to all interfaces + var bindAddress string + listenPort := 80 // default port + useIPv4 := config.NetworkConfig.IPv4Mode.String != "disabled" + useIPv6 := config.NetworkConfig.IPv6Mode.String != "disabled" + if config.LocalLoopbackOnly { - bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6) + if useIPv4 && useIPv6 { + bindAddress = fmt.Sprintf("localhost:%d", listenPort) + } else if useIPv4 { + bindAddress = fmt.Sprintf("127.0.0.1:%d", listenPort) + } else if useIPv6 { + bindAddress = fmt.Sprintf("[::1]:%d", listenPort) + } + } else { + if useIPv4 && useIPv6 { + bindAddress = fmt.Sprintf(":%d", listenPort) + } else if useIPv4 { + bindAddress = fmt.Sprintf("0.0.0.0:%d", listenPort) + } else if useIPv6 { + bindAddress = fmt.Sprintf("[::]:%d", listenPort) + } } logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") - err := r.Run(bindAddress) - if err != nil { + if err := r.Run(bindAddress); err != nil { panic(err) } } diff --git a/webrtc.go b/webrtc.go index c0f159aa..7fd13929 100644 --- a/webrtc.go +++ b/webrtc.go @@ -6,11 +6,15 @@ import ( "encoding/json" "net" "strings" + "sync" + "time" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/gin-gonic/gin" + "github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/usbgadget" "github.com/pion/webrtc/v4" "github.com/rs/zerolog" ) @@ -22,7 +26,29 @@ type Session struct { RPCChannel *webrtc.DataChannel HidChannel *webrtc.DataChannel shouldUmountVirtualMedia bool - rpcQueue chan webrtc.DataChannelMessage + + rpcQueue chan webrtc.DataChannelMessage + + hidRPCAvailable bool + lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived + lastTimerResetTime time.Time // Track when auto-release timer was last reset + keepAliveJitterLock sync.Mutex // Protect jitter compensation timing state + hidQueueLock sync.Mutex + hidQueue []chan hidQueueMessage + + keysDownStateQueue chan usbgadget.KeysDownState +} + +func (s *Session) resetKeepAliveTime() { + s.keepAliveJitterLock.Lock() + defer s.keepAliveJitterLock.Unlock() + s.lastKeepAliveArrivalTime = time.Time{} // Reset keep-alive timing tracking + s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking +} + +type hidQueueMessage struct { + webrtc.DataChannelMessage + channel string } type SessionConfig struct { @@ -67,6 +93,92 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) { return base64.StdEncoding.EncodeToString(localDescription), nil } +func (s *Session) initQueues() { + s.hidQueueLock.Lock() + defer s.hidQueueLock.Unlock() + + s.hidQueue = make([]chan hidQueueMessage, 0) + for i := 0; i < 4; i++ { + q := make(chan hidQueueMessage, 256) + s.hidQueue = append(s.hidQueue, q) + } +} + +func (s *Session) handleQueues(index int) { + for msg := range s.hidQueue[index] { + onHidMessage(msg, s) + } +} + +const keysDownStateQueueSize = 64 + +func (s *Session) initKeysDownStateQueue() { + // serialise outbound key state reports so unreliable links can't stall input handling + s.keysDownStateQueue = make(chan usbgadget.KeysDownState, keysDownStateQueueSize) + go s.handleKeysDownStateQueue() +} + +func (s *Session) handleKeysDownStateQueue() { + for state := range s.keysDownStateQueue { + s.reportHidRPCKeysDownState(state) + } +} + +func (s *Session) enqueueKeysDownState(state usbgadget.KeysDownState) { + if s == nil || s.keysDownStateQueue == nil { + return + } + + select { + case s.keysDownStateQueue <- state: + default: + hidRPCLogger.Warn().Msg("dropping keys down state update; queue full") + } +} + +func getOnHidMessageHandler(session *Session, scopedLogger *zerolog.Logger, channel string) func(msg webrtc.DataChannelMessage) { + return func(msg webrtc.DataChannelMessage) { + l := scopedLogger.With(). + Str("channel", channel). + Int("length", len(msg.Data)). + Logger() + // only log data if the log level is debug or lower + if scopedLogger.GetLevel() > zerolog.DebugLevel { + l = l.With().Str("data", string(msg.Data)).Logger() + } + + if msg.IsString { + l.Warn().Msg("received string data in HID RPC message handler") + return + } + + if len(msg.Data) < 1 { + l.Warn().Msg("received empty data in HID RPC message handler") + return + } + + l.Trace().Msg("received data in HID RPC message handler") + + // Enqueue to ensure ordered processing + queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0])) + if queueIndex >= len(session.hidQueue) || queueIndex < 0 { + l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found") + queueIndex = 3 + } + + queue := session.hidQueue[queueIndex] + if queue != nil { + queue <- hidQueueMessage{ + DataChannelMessage: msg, + channel: channel, + } + } else { + l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil") + return + } + } +} + func newSession(config SessionConfig) (*Session, error) { webrtcSettingEngine := webrtc.SettingEngine{ LoggerFactory: logging.GetPionDefaultLoggerFactory(), @@ -105,17 +217,41 @@ func newSession(config SessionConfig) (*Session, error) { scopedLogger.Warn().Err(err).Msg("Failed to create PeerConnection") return nil, err } + session := &Session{peerConnection: peerConnection} session.rpcQueue = make(chan webrtc.DataChannelMessage, 256) + session.initQueues() + session.initKeysDownStateQueue() + go func() { for msg := range session.rpcQueue { - onRPCMessage(msg, session) + // TODO: only use goroutine if the task is asynchronous + go onRPCMessage(msg, session) } }() + for i := 0; i < len(session.hidQueue); i++ { + go session.handleQueues(i) + } + peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { + defer func() { + if r := recover(); r != nil { + scopedLogger.Error().Interface("error", r).Msg("Recovered from panic in DataChannel handler") + } + }() + scopedLogger.Info().Str("label", d.Label()).Uint16("id", *d.ID()).Msg("New DataChannel") + switch d.Label() { + case "hidrpc": + session.HidChannel = d + d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc")) + // we won't send anything over the unreliable channels + case "hidrpc-unreliable-ordered": + d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-ordered")) + case "hidrpc-unreliable-nonordered": + d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-nonordered")) case "rpc": session.RPCChannel = d d.OnMessage(func(msg webrtc.DataChannelMessage) { @@ -191,6 +327,8 @@ func newSession(config SessionConfig) (*Session, error) { if connectionState == webrtc.ICEConnectionStateClosed { scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") if session == currentSession { + // Cancel any ongoing keyboard report multi when session closes + cancelKeyboardMacro() currentSession = nil } // Stop RPC processor @@ -198,6 +336,16 @@ func newSession(config SessionConfig) (*Session, error) { close(session.rpcQueue) session.rpcQueue = nil } + + // Stop HID RPC processor + for i := 0; i < len(session.hidQueue); i++ { + close(session.hidQueue[i]) + session.hidQueue[i] = nil + } + + close(session.keysDownStateQueue) + session.keysDownStateQueue = nil + if session.shouldUmountVirtualMedia { if err := rpcUnmountImage(); err != nil { scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")