mirror of https://github.com/jetkvm/kvm.git
Merge branch 'jetkvm:dev' into dev
This commit is contained in:
commit
b2b3ee40a7
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -301,13 +301,14 @@ export JETKVM_PROXY_URL="ws://<IP>"
|
|||
|
||||
### 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://<IP>:6060/debug/pprof/
|
||||
curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/
|
||||
```
|
||||
|
||||
### Advanced Environment Variables
|
||||
|
||||
```bash
|
||||
|
|
31
Makefile
31
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
|
||||
|
|
4
cloud.go
4
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
|
||||
|
|
20
config.go
20
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
|
||||
}
|
||||
|
||||
|
|
96
display.go
96
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 (
|
||||
|
|
42
go.mod
42
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
|
||||
)
|
||||
|
|
85
go.sum
85
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=
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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":
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
package usbgadget
|
||||
|
||||
import "time"
|
||||
|
||||
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
|
||||
|
||||
const hidWriteTimeout = 10 * time.Millisecond
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
14
jiggler.go
14
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)
|
||||
|
|
147
jsonrpc.go
147
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},
|
||||
|
|
1
log.go
1
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")
|
||||
|
|
9
main.go
9
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)
|
||||
}
|
||||
}()
|
||||
|
|
5
mdns.go
5
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
|
||||
|
|
27
network.go
27
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
Binary file not shown.
|
@ -1 +1 @@
|
|||
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521
|
||||
a4fca98710932aaa2765b57404e080105190cfa3af69171f4b4d95d4b78f9af0
|
||||
|
|
Binary file not shown.
|
@ -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
|
|
@ -6,27 +6,34 @@
|
|||
<!-- These are the fonts used in the app -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Medium.woff2"
|
||||
href="./public/fonts/CircularXXWeb-Medium.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Book.woff2"
|
||||
href="./public/fonts/CircularXXWeb-Book.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Regular.woff2"
|
||||
href="./public/fonts/CircularXXWeb-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="./public/fonts/CircularXXWeb-Black.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<title>JetKVM</title>
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="./public/fonts/fonts.css" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
|
@ -36,23 +43,21 @@
|
|||
<meta name="theme-color" content="#051946" />
|
||||
<meta name="description" content="A web-based KVM console for managing remote servers." />
|
||||
<script>
|
||||
// Initial theme setup
|
||||
document.documentElement.classList.toggle(
|
||||
"dark",
|
||||
localStorage.theme === "dark" ||
|
||||
function applyThemeFromPreference() {
|
||||
// dark theme setup
|
||||
var darkDesired = localStorage.theme === "dark" ||
|
||||
(!("theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches),
|
||||
);
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
|
||||
document.documentElement.classList.toggle("dark", darkDesired)
|
||||
}
|
||||
|
||||
// initial theme application
|
||||
applyThemeFromPreference();
|
||||
|
||||
// Listen for system theme changes
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", ({ matches }) => {
|
||||
if (!("theme" in localStorage)) {
|
||||
// Only auto-switch if user hasn't manually set a theme
|
||||
document.documentElement.classList.toggle("dark", matches);
|
||||
}
|
||||
});
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
|
||||
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
|
@ -1,8 +0,0 @@
|
|||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
|
||||
fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
|
||||
fill="black"/>
|
||||
<path d="M4 13H20V18H4V13Z" fill="black"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 511 B |
|
@ -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";
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { JSX } from "react";
|
||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||
import { Link, useNavigation } from "react-router";
|
||||
import type { FetcherWithComponents, LinkProps } from "react-router";
|
||||
|
||||
import ExtLink from "@/components/ExtLink";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
@ -175,7 +176,7 @@ type ButtonPropsType = Pick<
|
|||
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
|
||||
const classes = cx(
|
||||
"group outline-hidden",
|
||||
"group outline-hidden cursor-pointer",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
loading ? "pointer-events-none" : "",
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
<Card>
|
||||
<div className="p-2 text-black dark:text-white">
|
||||
<div className="font-semibold">
|
||||
<div className="px-2 py-1.5 text-black dark:text-white">
|
||||
<div className="text-[13px] font-semibold">
|
||||
{new Date(date * 1000).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="h-[2px] w-2 bg-blue-700" />
|
||||
<span >
|
||||
{stat} {toolTipData?.unit}
|
||||
<span className="text-[13px]">
|
||||
{metric} {toolTipData?.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
|||
<hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
||||
<div className="relative inline-block text-left">
|
||||
<Menu>
|
||||
<MenuButton className="h-full">
|
||||
<MenuButton as="div" className="h-full">
|
||||
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
|
||||
{picture ? (
|
||||
<img
|
||||
|
|
|
@ -10,10 +10,12 @@ import {
|
|||
VideoState
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { useHidRpc } from "@/hooks/useHidRpc";
|
||||
|
||||
export default function InfoBar() {
|
||||
const { keysDownState } = useHidStore();
|
||||
const { mouseX, mouseY, mouseMove } = useMouseStore();
|
||||
const { rpcHidStatus } = useHidRpc();
|
||||
|
||||
const videoClientSize = useVideoStore(
|
||||
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
|
@ -25,6 +27,7 @@ export default function InfoBar() {
|
|||
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
||||
const { isPasteInProgress } = useHidStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rpcDataChannel) return;
|
||||
|
@ -46,7 +49,7 @@ export default function InfoBar() {
|
|||
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
|
||||
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
|
||||
|
||||
return [...modifierNames,...keyNames].join(", ");
|
||||
return [...modifierNames, ...keyNames].join(", ");
|
||||
}, [keysDownState, showPressedKeys]);
|
||||
|
||||
return (
|
||||
|
@ -100,7 +103,18 @@ export default function InfoBar() {
|
|||
<span className="text-xs">{hdmiState}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debugMode && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">HidRPC State:</span>
|
||||
<span className="text-xs">{rpcHidStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
{isPasteInProgress && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Paste Mode:</span>
|
||||
<span className="text-xs">Enabled</span>
|
||||
</div>
|
||||
)}
|
||||
{showPressedKeys && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Keys:</span>
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
|
|||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||
{networkState?.dhcp_lease?.ip && (
|
||||
{networkState?.ipv6_link_local && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Link-local
|
||||
|
|
|
@ -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({
|
|||
)}
|
||||
</div>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
theme="light"
|
||||
TrailingIcon={LuEllipsisVertical}
|
||||
size="MD"
|
||||
></MenuButton>
|
||||
</div>
|
||||
|
||||
<MenuButton
|
||||
as={Button}
|
||||
theme="light"
|
||||
TrailingIcon={LuEllipsisVertical}
|
||||
size="MD"
|
||||
></MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { ComponentProps } from "react";
|
||||
import { cva, cx } from "cva";
|
||||
|
||||
import { someIterable } from "../utils";
|
||||
|
||||
import { GridCard } from "./Card";
|
||||
import MetricsChart from "./MetricsChart";
|
||||
|
||||
interface ChartPoint {
|
||||
date: number;
|
||||
metric: number | null;
|
||||
}
|
||||
|
||||
interface MetricProps<T, K extends keyof T> {
|
||||
title: string;
|
||||
description: string;
|
||||
stream?: Map<number, T>;
|
||||
metric?: K;
|
||||
data?: ChartPoint[];
|
||||
gate?: Map<number, unknown>;
|
||||
supported?: boolean;
|
||||
map?: (p: { date: number; metric: number | null }) => ChartPoint;
|
||||
domain?: [number, number];
|
||||
unit: string;
|
||||
heightClassName?: string;
|
||||
referenceValue?: number;
|
||||
badge?: ComponentProps<typeof MetricHeader>["badge"];
|
||||
badgeTheme?: ComponentProps<typeof MetricHeader>["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<T, K extends keyof T>(
|
||||
metrics: Map<number, T>,
|
||||
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 (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex w-full items-center justify-between text-base font-semibold text-black dark:text-white">
|
||||
{title}
|
||||
{badge && (
|
||||
<span
|
||||
className={cx(
|
||||
"ml-2 rounded-sm px-2 py-1 font-mono text-[10px] leading-none font-medium",
|
||||
badgeVariants({ theme: props.badgeTheme ?? "light" }),
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Metric<T, K extends keyof T>({
|
||||
title,
|
||||
description,
|
||||
stream,
|
||||
metric,
|
||||
data,
|
||||
gate,
|
||||
supported,
|
||||
map,
|
||||
domain = [0, 600],
|
||||
unit = "",
|
||||
heightClassName = "h-[127px]",
|
||||
badge,
|
||||
badgeTheme,
|
||||
}: MetricProps<T, K>) {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<MetricHeader
|
||||
title={title}
|
||||
description={description}
|
||||
badge={badge}
|
||||
badgeTheme={badgeTheme}
|
||||
/>
|
||||
|
||||
<GridCard>
|
||||
<div
|
||||
className={`flex ${heightClassName} w-full items-center justify-center text-sm text-slate-500`}
|
||||
>
|
||||
{!ready ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : supportedFinal ? (
|
||||
<MetricsChart
|
||||
data={dataFinal}
|
||||
domain={domain}
|
||||
unit={unit}
|
||||
referenceValue={referenceValue}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-black">Metric not supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 && (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
strokeDasharray="3 3"
|
||||
|
@ -64,7 +64,7 @@ export default function StatChart({
|
|||
.map(x => x.date)}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="stat"
|
||||
dataKey="metric"
|
||||
axisLine={false}
|
||||
orientation="right"
|
||||
tick={{
|
||||
|
@ -73,6 +73,7 @@ export default function StatChart({
|
|||
fill: "rgba(107, 114, 128, 1)",
|
||||
}}
|
||||
padding={{ top: 0, bottom: 0 }}
|
||||
allowDecimals
|
||||
tickLine={false}
|
||||
unit={unit}
|
||||
domain={domain || ["auto", "auto"]}
|
||||
|
@ -87,7 +88,7 @@ export default function StatChart({
|
|||
<Line
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
dataKey="stat"
|
||||
dataKey="metric"
|
||||
stroke="rgb(29 78 216)"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={2}
|
|
@ -1,4 +1,4 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Link } from "react-router";
|
||||
import React from "react";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
|
|
|
@ -22,6 +22,39 @@ const USBStateMap: Record<USBStates, string> = {
|
|||
"not attached": "Disconnected",
|
||||
suspended: "Low power mode",
|
||||
};
|
||||
const StatusCardProps: StatusProps = {
|
||||
configured: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 shrink-0",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
attached: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
addressed: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
"not attached": {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
suspended: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
};
|
||||
|
||||
export default function USBStateStatus({
|
||||
state,
|
||||
|
@ -30,39 +63,7 @@ export default function USBStateStatus({
|
|||
state: USBStates;
|
||||
peerConnectionState?: RTCPeerConnectionState | null;
|
||||
}) {
|
||||
const StatusCardProps: StatusProps = {
|
||||
configured: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 shrink-0",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
attached: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
addressed: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
"not attached": {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
suspended: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
};
|
||||
|
||||
const props = StatusCardProps[state];
|
||||
if (!props) {
|
||||
console.warn("Unsupported USB state: ", state);
|
||||
|
|
|
@ -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 <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
|
||||
};
|
||||
|
||||
const AttachIcon = ({ className }: { className?: string }) => {
|
||||
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
|
||||
};
|
||||
|
||||
function KeyboardWrapper() {
|
||||
const keyboardRef = useRef<HTMLDivElement>(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,27 +42,32 @@ 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) => {
|
||||
|
@ -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() {
|
|||
<div
|
||||
className={cx(
|
||||
!isAttachedVirtualKeyboardVisible
|
||||
? "fixed left-0 top-0 z-50 select-none"
|
||||
? "fixed top-0 left-0 z-10 select-none"
|
||||
: "relative",
|
||||
)}
|
||||
ref={keyboardRef}
|
||||
|
@ -224,9 +235,10 @@ function KeyboardWrapper() {
|
|||
<Card
|
||||
className={cx("overflow-hidden", {
|
||||
"rounded-none": isAttachedVirtualKeyboardVisible,
|
||||
"keyboard-detached": !isAttachedVirtualKeyboardVisible,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-4 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||
<div className="absolute left-2 flex items-center gap-x-2">
|
||||
{isAttachedVirtualKeyboardVisible ? (
|
||||
<Button
|
||||
|
@ -240,15 +252,25 @@ function KeyboardWrapper() {
|
|||
size="XS"
|
||||
theme="light"
|
||||
text="Attach"
|
||||
LeadingIcon={AttachIcon}
|
||||
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
|
||||
Virtual Keyboard
|
||||
</h2>
|
||||
<div className="absolute right-2">
|
||||
<div className="absolute right-2 flex items-center gap-x-2">
|
||||
<div className="hidden md:flex gap-x-2 items-center">
|
||||
<LinkButton
|
||||
size="XS"
|
||||
to="settings/keyboard"
|
||||
theme="light"
|
||||
text={selectedKeyboard.name}
|
||||
LeadingIcon={LuKeyboard}
|
||||
/>
|
||||
<div className="h-[20px] w-px bg-slate-800/20 dark:bg-slate-200/20" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
|
@ -317,7 +339,7 @@ function KeyboardWrapper() {
|
|||
stopMouseUpPropagation={true}
|
||||
/>
|
||||
</div>
|
||||
{ /* TODO add optional number pad */ }
|
||||
{/* TODO add optional number pad */}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
@ -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) => {
|
||||
|
@ -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];
|
||||
|
||||
|
@ -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<HTMLDivElement>(null);
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<HTMLTextAreaElement>(null);
|
||||
const { setPasteModeEnabled } = useHidStore();
|
||||
const { isPasteInProgress } = useHidStore();
|
||||
const { setDisableVideoFocusTrap } = useUiStore();
|
||||
|
||||
const { send } = useJsonRpc();
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { executeMacro, cancelExecuteMacro } = useKeyboard();
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const [delayValue, setDelayValue] = useState(100);
|
||||
const delay = useMemo(() => {
|
||||
if (delayValue < 50 || delayValue > 65534) {
|
||||
return 100;
|
||||
}
|
||||
return delayValue;
|
||||
}, [delayValue]);
|
||||
const close = useClose();
|
||||
|
||||
const 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<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload(stroke.modifier, stroke.keys),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
|
||||
}, [selectedKeyboard, executeMacro, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (TextAreaRef.current) {
|
||||
|
@ -122,19 +126,25 @@ export default function PasteModal() {
|
|||
/>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn opacity-0 space-y-2"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
<div
|
||||
className="w-full"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()} onKeyDownCapture={e => e.stopPropagation()}
|
||||
onKeyUpCapture={e => e.stopPropagation()}
|
||||
>
|
||||
<TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label="Paste from host"
|
||||
rows={4}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
maxLength={pasteMaxLength}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
|
@ -171,9 +181,31 @@ export default function PasteModal() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx("text-xs text-slate-600 dark:text-slate-400", delayClassName)}>
|
||||
<InputFieldWithLabel
|
||||
type="number"
|
||||
label="Delay between keys"
|
||||
placeholder="Delay between keys"
|
||||
min={50}
|
||||
max={65534}
|
||||
value={delayValue}
|
||||
onChange={e => {
|
||||
setDelayValue(parseInt(e.target.value, 10));
|
||||
}}
|
||||
/>
|
||||
{delayValue < 50 || delayValue > 65534 && (
|
||||
<div className="mt-2 flex items-center gap-x-2">
|
||||
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||
<span className="text-xs text-red-500 dark:text-red-400">
|
||||
Delay must be between 50 and 65534
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
|
||||
Sending text using keyboard layout: {selectedKeyboard.isoCode}-
|
||||
{selectedKeyboard.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -181,7 +213,7 @@ export default function PasteModal() {
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex animate-fadeIn opacity-0 items-center justify-end gap-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -200,6 +232,7 @@ export default function PasteModal() {
|
|||
size="SM"
|
||||
theme="primary"
|
||||
text="Confirm Paste"
|
||||
disabled={isPasteInProgress}
|
||||
onClick={onConfirmPaste}
|
||||
LeadingIcon={LuCornerDownLeft}
|
||||
/>
|
||||
|
|
|
@ -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<T, K extends keyof T>(
|
||||
stream: Map<number, T>,
|
||||
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<T, K extends keyof T>(
|
||||
stream: Map<number, T>,
|
||||
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 (
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
||||
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
|
||||
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
|
||||
<div className="space-y-4">
|
||||
{/*
|
||||
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" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Packets Lost
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Number of data packets lost during transmission.
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "packetsLost")}
|
||||
domain={[0, 100]}
|
||||
unit=" packets"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-black">Metric not supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
<div className="space-y-8">
|
||||
{/* Connection Group */}
|
||||
<div className="space-y-3">
|
||||
<SettingsSectionHeader
|
||||
title="Connection"
|
||||
description="The connection between the client and the JetKVM."
|
||||
/>
|
||||
<Metric
|
||||
title="Round-Trip Time"
|
||||
description="Round-trip time for the active ICE candidate pair between peers."
|
||||
stream={iceCandidatePairStats}
|
||||
metric="currentRoundTripTime"
|
||||
map={x => ({
|
||||
date: x.date,
|
||||
metric: x.metric != null ? Math.round(x.metric * 1000) : null,
|
||||
})}
|
||||
domain={[0, 600]}
|
||||
unit=" ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Round-Trip Time
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Time taken for data to travel from source to destination and back
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
|
||||
<StatChart
|
||||
data={createChartArray(
|
||||
candidatePairStats,
|
||||
"currentRoundTripTime",
|
||||
).map(x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? Math.round(x.stat * 1000) : null,
|
||||
};
|
||||
})}
|
||||
domain={[0, 600]}
|
||||
unit=" ms"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-black">Metric not supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Jitter
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Variation in packet delay, affecting video smoothness.{" "}
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "jitter").map(x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? Math.round(x.stat * 1000) : null,
|
||||
};
|
||||
})}
|
||||
domain={[0, 300]}
|
||||
unit=" ms"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Frames per second
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Number of video frames displayed per second.
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
|
||||
x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? x.stat : null,
|
||||
};
|
||||
},
|
||||
)}
|
||||
domain={[0, 80]}
|
||||
unit=" fps"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
|
||||
{/* Video Group */}
|
||||
<div className="space-y-3">
|
||||
<SettingsSectionHeader
|
||||
title="Video"
|
||||
description="The video stream from the JetKVM to the client."
|
||||
/>
|
||||
|
||||
{/* RTP Jitter */}
|
||||
<Metric
|
||||
title="Network Stability"
|
||||
badge="Jitter"
|
||||
badgeTheme="light"
|
||||
description="How steady the flow of inbound video packets is across the network."
|
||||
stream={inboundVideoRtpStats}
|
||||
metric="jitter"
|
||||
map={x => ({
|
||||
date: x.date,
|
||||
metric: x.metric != null ? Math.round(x.metric * 1000) : null,
|
||||
})}
|
||||
domain={[0, 10]}
|
||||
unit=" ms"
|
||||
/>
|
||||
|
||||
{/* Playback Delay */}
|
||||
<Metric
|
||||
title="Playback Delay"
|
||||
description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly."
|
||||
badge="Jitter Buffer Avg. Delay"
|
||||
badgeTheme="light"
|
||||
data={jitterBufferAvgDelayData}
|
||||
gate={inboundVideoRtpStats}
|
||||
supported={
|
||||
someIterable(
|
||||
inboundVideoRtpStats,
|
||||
([, x]) => x.jitterBufferDelay != null,
|
||||
) &&
|
||||
someIterable(
|
||||
inboundVideoRtpStats,
|
||||
([, x]) => x.jitterBufferEmittedCount != null,
|
||||
)
|
||||
}
|
||||
domain={[0, 30]}
|
||||
unit=" ms"
|
||||
/>
|
||||
|
||||
{/* Packets Lost */}
|
||||
<Metric
|
||||
title="Packets Lost"
|
||||
description="Count of lost inbound video RTP packets."
|
||||
stream={inboundVideoRtpStats}
|
||||
metric="packetsLost"
|
||||
domain={[0, 100]}
|
||||
unit=" packets"
|
||||
/>
|
||||
|
||||
{/* Frames Per Second */}
|
||||
<Metric
|
||||
title="Frames per second"
|
||||
description="Number of inbound video frames displayed per second."
|
||||
stream={inboundVideoRtpStats}
|
||||
metric="framesPerSecond"
|
||||
domain={[0, 80]}
|
||||
unit=" fps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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<RTCState>(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<HidState>(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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -29,6 +29,8 @@ export interface JsonRpcErrorResponse {
|
|||
|
||||
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
|
||||
|
||||
export const RpcMethodNotFound = -32601;
|
||||
|
||||
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
|
||||
let requestCounter = 0;
|
||||
|
||||
|
|
|
@ -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<void> => 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<AbortController | null>(null);
|
||||
const setAbortController = useCallback((ac: AbortController | null) => {
|
||||
abortController.current = ac;
|
||||
}, []);
|
||||
|
||||
// Keepalive timer management
|
||||
const keepAliveTimerRef = useRef<number | null>(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
|
||||
|
@ -16,116 +54,99 @@ export default function useKeyboard() {
|
|||
// 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
|
||||
// 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<void>((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
|
||||
|
@ -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<void>)[] = [];
|
||||
|
||||
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<void>((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 };
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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<SystemVersionInfo>((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<VersionInfo>((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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -144,33 +144,33 @@ export const keyDisplayMap: Record<string, string> = {
|
|||
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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: <ErrorBoundary />,
|
||||
element: <DeviceRoute />,
|
||||
HydrateFallback: () => <div className="p-4">Loading...</div>,
|
||||
loader: DeviceRoute.loader,
|
||||
children: [
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
function Root() {
|
||||
return <Outlet />;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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`)
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
|
|
@ -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 <Dialog onClose={() => 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<AbortController | null>(null);
|
||||
const { setAppVersion, setSystemVersion } = useDeviceStore();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const getVersionInfo = useCallback(() => {
|
||||
return new Promise<SystemVersionInfo>((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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
|
|
|
@ -53,7 +53,7 @@ export default function SettingsKeyboardRoute() {
|
|||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Paste text"
|
||||
title="Keyboard Layout"
|
||||
description="Keyboard layout of target operating system"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
|
@ -66,7 +66,7 @@ export default function SettingsKeyboardRoute() {
|
|||
/>
|
||||
</SettingsItem>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation } from "react-router";
|
||||
import {
|
||||
LuSettings,
|
||||
LuMouse,
|
||||
|
|
|
@ -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<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(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"
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
@ -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"
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
@ -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"
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
@ -205,60 +220,64 @@ export default function SettingsVideoRoute() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description="Adjust the EDID settings for the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
fullWidth
|
||||
value={customEdidValue ? "custom" : edid || "asd"}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue("");
|
||||
} else {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(e.target.value as string);
|
||||
}
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Custom EDID"
|
||||
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label="EDID File"
|
||||
placeholder="00F..."
|
||||
rows={3}
|
||||
value={customEdidValue}
|
||||
onChange={e => setCustomEdidValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-start gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Set Custom EDID"
|
||||
onClick={() => handleEDIDChange(customEdidValue)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Restore to default"
|
||||
onClick={() => {
|
||||
<Fieldset disabled={edidLoading} className="space-y-2">
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description="Adjust the EDID settings for the display"
|
||||
loading={edidLoading}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
fullWidth
|
||||
value={customEdidValue ? "custom" : edid || "asd"}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue("");
|
||||
} else {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(defaultEdid);
|
||||
}}
|
||||
handleEDIDChange(e.target.value as string);
|
||||
}
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Custom EDID"
|
||||
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextAreaWithLabel
|
||||
label="EDID File"
|
||||
placeholder="00F..."
|
||||
rows={3}
|
||||
value={customEdidValue}
|
||||
onChange={e => setCustomEdidValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-start gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Set Custom EDID"
|
||||
loading={edidLoading}
|
||||
onClick={() => handleEDIDChange(customEdidValue)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Restore to default"
|
||||
loading={edidLoading}
|
||||
onClick={() => {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(defaultEdid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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" };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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<string>): Promise<CloudLoaderResp> =>
|
|||
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 =
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<DeviceStatus>);
|
||||
|
@ -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}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router";
|
||||
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router";
|
||||
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
|
||||
|
|
|
@ -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<DeviceStatus>);
|
||||
|
@ -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;
|
||||
|
|
|
@ -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<DeviceStatus>);
|
||||
|
@ -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;
|
||||
|
|
|
@ -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<DeviceStatus>);
|
||||
|
|
|
@ -94,6 +94,17 @@ export const formatters = {
|
|||
},
|
||||
};
|
||||
|
||||
export function someIterable<T>(
|
||||
iterable: Iterable<T>,
|
||||
predicate: (item: T) => boolean,
|
||||
): boolean {
|
||||
for (const item of iterable) {
|
||||
if (predicate(item)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const VIDEO = new Blob(
|
||||
[
|
||||
new Uint8Array([
|
||||
|
|
|
@ -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" : "/",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue