Compare commits

..

21 Commits

Author SHA1 Message Date
Siyuan Miao fe77acd5f0 chore: bump to 0.4.8 2025-09-22 12:51:19 +02:00
Aveline 83caa8f82d
feat: get local version only (#813) 2025-09-19 13:45:59 +02:00
Adam Shiervani 27750b9cc2
feat: re-add keyboard and keypress report handlers to RPC (#811) 2025-09-18 17:33:08 +02:00
Adam Shiervani 5112bef19c
fix: remove unnecessary grow-0 utility from in keyboard (#810) 2025-09-18 17:02:08 +02:00
Siyuan Miao 1ffdca4fd6 build: use immediate assignment for VERSION_DEV and other vars 2025-09-18 15:41:39 +02:00
Siyuan Miao c6dba4d59f chore: bump to 0.4.7 2025-09-18 13:53:08 +02:00
Aveline afb146d78c
feat: release keyPress automatically (#796)
* feat: release keyPress automatically

* send keepalive when pressing the key

* remove logging

* clean up logging

* chore: use unreliable channel to send keepalive events

* chore: use ordered unreliable channel for pointer events

* chore: adjust auto release key interval

* chore: update logging for kbdAutoReleaseLock

* chore: update comment for KEEPALIVE_INTERVAL

* fix: should cancelAutorelease when pressed is true

* fix: handshake won't happen if webrtc reconnects

* chore: add trace log for writeWithTimeout

* chore: add timeout for KeypressReport

* chore: use the proper key to send release command

* refactor: simplify HID RPC keyboard input handling and improve key state management

- Updated `handleHidRPCKeyboardInput` to return errors directly instead of keys down state.
- Refactored `rpcKeyboardReport` and `rpcKeypressReport` to return errors instead of states.
- Introduced a queue for managing key down state updates in the `Session` struct to prevent input handling stalls.
- Adjusted the `UpdateKeysDown` method to handle state changes more efficiently.
- Removed unnecessary logging and commented-out code for clarity.

* refactor: enhance keyboard auto-release functionality and key state management

* fix: correct Windows default auto-repeat delay comment from 1ms to 1s

* refactor: send keypress as early as possible

* refactor: replace console.warn with console.info for HID RPC channel events

* refactor: remove unused NewKeypressKeepAliveMessage function from HID RPC

* fix: handle error in key release process and log warnings

* fix: log warning on keypress report failure

* fix: update auto-release keyboard interval to 225

* refactor: enhance keep-alive handling and jitter compensation in HID RPC

- Implemented staleness guard to ignore outdated keep-alive packets.
- Added jitter compensation logic to adjust timer extensions based on packet arrival times.
- Introduced new methods for managing keep-alive state and reset functionality in the Session struct.
- Updated auto-release delay mechanism to use dynamic durations based on keep-alive timing.
- Adjusted keep-alive interval in the UI to improve responsiveness.

* gofmt

* clean up code

* chore: use dynamic duration for scheduleAutoRelease

* Use harcoded timer reset value for now

* fix: prevent nil pointer dereference when stopping timers in Close method

* refactor: remove nil check for kbdAutoReleaseTimers in DelayAutoReleaseWithDuration

* refactor: optimize dependencies in useHidRpc hooks

* refactor: streamline keep-alive timer management in useKeyboard hook

* refactor: clarify comments in useKeyboard hook for resetKeyboardState function

* refactor: reduce keysDownStateQueueSize

* refactor: close and reset keysDownStateQueue in newSession function

* chore: resolve conflicts

* resolve conflicts

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2025-09-18 13:35:47 +02:00
Aveline 72e3013337
feat: send all paste keystrokes to backend (#789)
* feat: send all paste keystrokes to backend

* feat: cancel paste mode

* wip: send macro using hidRPC channel

* add delay

* feat: allow paste progress to be cancelled

* allow user to override delay

* chore: clear keysDownState

* fix: use currentSession.reportHidRPCKeyboardMacroState

* fix: jsonrpc.go:1142:21: Error return value is not checked (errcheck)

* fix: performance issue of Uint8Array concat

* chore: hide delay option when debugMode isn't enabled

* feat: use clientSide macro if backend doesn't support macros

* fix: update keysDownState handling

* minor issues

* refactor

* fix: send duplicated keyDownState

* chore: add max length for paste text

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2025-09-18 13:00:57 +02:00
Marc 25b102ac34
fix: ensure that security-key backed SSH keys are supported (#807) 2025-09-17 12:14:45 +02:00
Aveline 5c94c6c87f
chore: upgrade jetkvm native and fix the params of fadeIn / fadeOut (#808) 2025-09-17 01:38:23 +02:00
Marc Brooks cf679978be
fix(timesync): ensure that auto-update waits for time sync (#609)
- Added check to not attempt auto update if time sync is needed and not yet successful (delays 30 second to recheck).
- Added resync of time when DHCP or link state changes if online
- Added conditional* fallback from configured* NTP servers to the IP-named NTP servers, and then to the DNS named ones if that fails
- Added conditional* fallback from the configured* HTTP servers to the default DNS named ones.
- Uses the configuration* option for how many queries to run in parallel
- Added known static IPs for time servers (in case DNS resolution isn't up yet)
- Added time.cloudflare.com to fall-back NTP servers
- Added fallback to NTP via hostnames
- Logs the resultant time (and mode)
2025-09-16 15:37:02 +02:00
Marc Brooks 80a8b9e9e3
feat: Adds IPv6 disabling feature (#803)
* Allow disabling IPv6

Simply ignores any IPv6 addresses in the lease and doesn't offer them to the RPC
Also fixed display issue for IPv6 link local address.
Fixes https://github.com/orgs/jetkvm/projects/7/views/1?pane=issue&itemId=122761546&issue=jetkvm%7Ckvm%7C685

* Don't listen on disabled addresses in mDNS or web server.

* We have to set the IPv4 and IPv6 modes on the server.
2025-09-16 12:44:56 +02:00
Aveline 1717549578
fix: goroutine leak issue of cloudBlink (#801)
* fix: goroutine leak issue of cloudBlink

* chore: add lock and allow context to be cancelled earlier
2025-09-12 18:30:35 +02:00
Aveline 37b1a8bf34
docs: update pprof section of DEVELOPMENT.md (#802) 2025-09-12 11:11:28 +02:00
Marc Brooks ca8b06f4cf
chore: enhance the gzip and cacheable handling of static files
Add SVG and ICO to cacheable files.
Emit robots.txt directly.
Recognize WOFF2 (font) files as assets (so the get the immutable treatment)
Pre-gzip the entire /static/ directory (not just /static/assets/) and include SVG, ICO, and HTML files
Ensure fonts.css is processed by vite/rollup so that the preload and css reference the same immutable files (which get long-cached with hashes)
Add CircularXXWeb-Black to the preload list as it is used in the hot-path.
Handle system-driven color-scheme changes from dark to light correctly.
2025-09-12 08:41:41 +02:00
Aveline 33e099f258
update netboot.xyz-multiarch.iso to 2.0.88 (#799)
* chore: update netboot.xyz-multiarch.iso to 2.0.88

* feat: add script to update netboot.xyz iso
2025-09-12 08:41:17 +02:00
Aveline ea068414dc
feat: validate ssh public key before saving (#794)
* feat: validate ssh public key before saving

* fix: TestValidSSHKeyTypes
2025-09-11 23:32:40 +02:00
Adam Shiervani 8d1a66806c
refactor(ui): Don't fetch KeybardAndMouse Icon on every re-render (#795) 2025-09-11 19:57:35 +02:00
Aveline 6202e3cafa
chore: serve pre-compressed static files (#793) 2025-09-11 19:17:15 +02:00
dependabot[bot] c866230711
build(deps-dev): bump vite (#788)
Bumps the npm_and_yarn group with 1 update in the /ui directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.4 to 7.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 12:07:52 +02:00
Marc Brooks c8dd84c6b7
fix/Jiggler settings not saving (#786)
Ensure the jiggler config loads the defaults so they can be saved.
Ensure the file.Sync occurs before acknowledging save.
Also fixup the old KeyboardLayout to use en-US not en_US
2025-09-09 14:48:49 +02:00
53 changed files with 2273 additions and 572 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

3
go.mod
View File

@ -12,6 +12,7 @@ require (
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.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-20250901182336-dc5ae18bd79f
@ -23,6 +24,7 @@ require (
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.11.1
@ -81,6 +83,7 @@ require (
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

5
go.sum
View File

@ -53,6 +53,8 @@ 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=
@ -152,6 +154,7 @@ 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=
@ -171,6 +174,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/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=

129
hidrpc.go
View File

@ -1,11 +1,14 @@
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) {
@ -24,11 +27,19 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
keysDownState, err := handleHidRPCKeyboardInput(message)
if keysDownState != nil {
session.reportHidRPCKeysDownState(*keysDownState)
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 = err
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 {
@ -52,8 +63,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
}
func onHidMessage(data []byte, session *Session) {
scopedLogger := hidRPCLogger.With().Bytes("data", data).Logger()
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 {
@ -68,7 +84,9 @@ func onHidMessage(data []byte, session *Session) {
return
}
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
if scopedLogger.GetLevel() <= zerolog.DebugLevel {
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
}
t := time.Now()
@ -85,27 +103,88 @@ func onHidMessage(data []byte, session *Session) {
}
}
func handleHidRPCKeyboardInput(message hidrpc.Message) (*usbgadget.KeysDownState, error) {
// 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 nil, err
return err
}
keysDownState, rpcError := rpcKeypressReport(keypressReport.Key, keypressReport.Press)
return &keysDownState, rpcError
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 nil, err
return err
}
keysDownState, rpcError := rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
return &keysDownState, rpcError
return rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
}
return nil, fmt.Errorf("unknown HID RPC message type: %d", message.Type())
return fmt.Errorf("unknown HID RPC message type: %d", message.Type())
}
func reportHidRPC(params any, session *Session) {
@ -115,7 +194,10 @@ func reportHidRPC(params any, session *Session) {
}
if !session.hidRPCAvailable || session.HidChannel == nil {
logger.Warn().Msg("HID RPC is not available, skipping reportHidRPC")
logger.Warn().
Bool("hidRPCAvailable", session.hidRPCAvailable).
Bool("HidChannel", session.HidChannel != nil).
Msg("HID RPC is not available, skipping reportHidRPC")
return
}
@ -128,6 +210,8 @@ func reportHidRPC(params any, session *Session) {
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)
}
@ -143,6 +227,10 @@ func reportHidRPC(params any, session *Session) {
}
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")
}
}
@ -156,7 +244,16 @@ func (s *Session) reportHidRPCKeyboardLedState(state usbgadget.KeyboardState) {
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)
}

View File

@ -10,14 +10,18 @@ import (
type MessageType byte
const (
TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
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 (
@ -29,10 +33,13 @@ func GetQueueIndex(messageType MessageType) int {
switch messageType {
case TypeHandshake:
return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState:
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
}
@ -98,3 +105,19 @@ func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
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,
}
}

View File

@ -1,6 +1,7 @@
package hidrpc
import (
"encoding/binary"
"fmt"
)
@ -43,6 +44,13 @@ func (m *Message) String() string {
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)
}
@ -84,6 +92,55 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
}, 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
@ -131,3 +188,20 @@ func (m *Message) MouseReport() (MouseReport, error) {
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
}

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

@ -5,7 +5,11 @@ import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/rs/xid"
"github.com/rs/zerolog"
)
var keyboardConfig = gadgetConfigItem{
@ -145,32 +149,95 @@ func (u *UsbGadget) GetKeysDownState() KeysDownState {
return u.keysDownState
}
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("acquiring keyboardStateLock for updateKeyDownState")
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
}
// this is intentional to unlock keyboard state lock before onKeysDownChange callback
{
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
func (u *UsbGadget) SetOnKeepAliveReset(f func()) {
u.onKeepAliveReset = &f
}
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
return // No change in key down state
}
// DefaultAutoReleaseDuration is the default duration for auto-release of a key.
const DefaultAutoReleaseDuration = 100 * time.Millisecond
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
u.keysDownState = state
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()
}
if u.onKeysDownChange != nil {
u.log.Trace().Interface("state", state).Msg("calling onKeysDownChange")
(*u.onKeysDownChange)(state)
u.log.Trace().Interface("state", state).Msg("onKeysDownChange called")
// 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)
})
}
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() {
@ -242,7 +309,11 @@ 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
}
@ -266,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 {
@ -291,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 (
@ -331,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...)
@ -381,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
}

View File

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

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
@ -120,6 +121,12 @@ func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err err
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()),
@ -164,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()
}

73
internal/utils/ssh.go Normal file
View File

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

220
internal/utils/ssh_test.go Normal file
View File

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

View File

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

View File

@ -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 {
@ -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
@ -1049,6 +1070,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"}},
@ -1059,10 +1177,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"}},
@ -1084,6 +1202,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},

View File

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

View File

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

View File

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

BIN
resource/jetkvm_native Normal file → Executable file

Binary file not shown.

View File

@ -1 +1 @@
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521
a4fca98710932aaa2765b57404e080105190cfa3af69171f4b4d95d4b78f9af0

Binary file not shown.

77
scripts/update_netboot_xyz.sh Executable file
View File

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

View File

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

80
ui/package-lock.json generated
View File

@ -68,7 +68,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4"
},
"engines": {
@ -1793,6 +1793,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@ -6563,13 +6623,13 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@ -6893,9 +6953,9 @@
}
},
"node_modules/vite": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz",
"integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@ -6903,7 +6963,7 @@
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"

View File

@ -79,7 +79,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -27,6 +27,7 @@ export default function InfoBar() {
const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const { isPasteInProgress } = useHidStore();
useEffect(() => {
if (!rpcDataChannel) return;
@ -108,7 +109,12 @@ export default function InfoBar() {
<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>

View File

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

View File

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

View File

@ -190,7 +190,7 @@ export default function WebRTCVideo() {
if (!isFullscreenEnabled || !videoElm.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my
// If keyboard lock is activated after fullscreen is already in effect, then the user my
// see multiple messages about how to exit fullscreen. For this reason, we recommend that
// developers call lock() before they enter fullscreen:
await requestKeyboardLock();
@ -237,6 +237,7 @@ export default function WebRTCVideo() {
const keyDownHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
if (e.repeat) return;
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];

View File

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

View File

@ -1,4 +1,4 @@
import { KeyboardLedState, KeysDownState } from "./stores";
import { hidKeyBufferSize, KeyboardLedState, KeysDownState } from "./stores";
export const HID_RPC_MESSAGE_TYPES = {
Handshake: 0x01,
@ -6,9 +6,13 @@ export const HID_RPC_MESSAGE_TYPES = {
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];
@ -28,7 +32,31 @@ const fromInt32toUint8 = (n: number) => {
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 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,
]);
};
@ -37,7 +65,7 @@ const fromInt8ToUint8 = (n: number) => {
throw new Error(`Number ${n} is not within the int8 range`);
}
return (n >> 0) & 0xFF;
return n & 0xFF;
};
const keyboardLedStateMasks = {
@ -186,6 +214,99 @@ export class KeyboardReportMessage extends RpcMessage {
}
}
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;
@ -256,6 +377,17 @@ export class PointerReportMessage extends RpcMessage {
}
}
export class CancelKeyboardMacroReportMessage extends RpcMessage {
constructor() {
super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport);
}
marshal(): Uint8Array {
return new Uint8Array([this.messageType]);
}
}
export class MouseReportMessage extends RpcMessage {
dx: number;
dy: number;
@ -278,12 +410,26 @@ export class MouseReportMessage extends RpcMessage {
}
}
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 => {

View File

@ -105,12 +105,21 @@ export interface RTCState {
setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean;
setHidRpcDisabled: (disabled: boolean) => void;
rpcHidProtocolVersion: number | null;
setRpcHidProtocolVersion: (version: number) => void;
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;
@ -157,12 +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) => set({ rpcHidProtocolVersion: version }),
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 }),
@ -464,7 +482,7 @@ export interface HidState {
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
isPasteModeEnabled: boolean;
isPasteInProgress: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
usbState: USBStates;
@ -481,8 +499,8 @@ export const useHidStore = create<HidState>(set => ({
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",

View File

@ -3,9 +3,13 @@ import { useCallback, useEffect, useMemo } from "react";
import { useRTCStore } from "@/hooks/stores";
import {
CancelKeyboardMacroReportMessage,
HID_RPC_VERSION,
HandshakeMessage,
KeyboardMacroStep,
KeyboardMacroReportMessage,
KeyboardReportMessage,
KeypressKeepAliveMessage,
KeypressReportMessage,
MouseReportMessage,
PointerReportMessage,
@ -13,38 +17,97 @@ import {
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, setRpcHidProtocolVersion, rpcHidProtocolVersion } = useRTCStore();
const {
rpcHidChannel,
rpcHidUnreliableChannel,
rpcHidUnreliableNonOrderedChannel,
setRpcHidProtocolVersion,
rpcHidProtocolVersion, hidRpcDisabled,
} = useRTCStore();
const rpcHidReady = useMemo(() => {
if (hidRpcDisabled) return false;
return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidChannel, rpcHidProtocolVersion]);
}, [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})`;
}, [rpcHidChannel, rpcHidProtocolVersion]);
return `ready (v${rpcHidProtocolVersion}${rpcHidUnreliableReady ? "+u" : ""})`;
}, [rpcHidChannel, rpcHidProtocolVersion, rpcHidUnreliableReady, hidRpcDisabled]);
const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => {
const sendMessage = useCallback(
(
message: RpcMessage,
{
ignoreHandshakeState,
useUnreliableChannel,
requireOrdered = true,
}: sendMessageParams = {},
) => {
if (hidRpcDisabled) return;
if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) 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;
let data: Uint8Array | undefined;
try {
data = message.marshal();
} catch (e) {
console.error("Failed to send HID RPC message", e);
}
if (!data) return;
rpcHidChannel?.send(data as unknown as ArrayBuffer);
}, [rpcHidChannel, rpcHidReady]);
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],
},
[sendMessage],
);
const reportKeypressEvent = useCallback(
@ -56,7 +119,9 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const reportAbsMouseEvent = useCallback(
(x: number, y: number, buttons: number) => {
sendMessage(new PointerReportMessage(x, y, buttons));
sendMessage(new PointerReportMessage(x, y, buttons), {
useUnreliableChannel: true,
});
},
[sendMessage],
);
@ -68,32 +133,57 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[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), true);
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]);
sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true });
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]);
const handleHandshake = useCallback(
(message: HandshakeMessage) => {
if (hidRpcDisabled) return;
const handleHandshake = useCallback((message: HandshakeMessage) => {
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
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;
}
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]);
setRpcHidProtocolVersion(message.version);
},
[setRpcHidProtocolVersion, hidRpcDisabled],
);
useEffect(() => {
if (!rpcHidChannel) return;
if (hidRpcDisabled) return;
// send handshake message
sendHandshake();
@ -123,26 +213,42 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
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,
],
);
}, [
rpcHidChannel,
onHidRpcMessage,
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
hidRpcDisabled,
]);
return {
reportKeyboardEvent,
reportKeypressEvent,
reportAbsMouseEvent,
reportRelMouseEvent,
reportKeyboardMacroEvent,
cancelOngoingKeyboardMacro,
reportKeypressKeepAlive,
rpcHidProtocolVersion,
rpcHidReady,
rpcHidStatus,

View File

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

View File

@ -1,15 +1,51 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import {
KeyboardLedStateMessage,
KeyboardMacroStateMessage,
KeyboardMacroStep,
KeysDownStateMessage,
} from "@/hooks/hidRpc";
import {
hidErrorRollOver,
hidKeyBufferSize,
KeysDownState,
useHidStore,
useRTCStore,
} from "@/hooks/stores";
import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc";
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, setKeyboardLedState } = 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
@ -17,17 +53,19 @@ export default function useKeyboard() {
// is running on the cloud against a device that has not been updated yet and thus does not
// support the keyPressReport API. In that case, we need to handle the key presses locally
// and send the full state to the device, so it can behave like a real USB HID keyboard.
// This flag indicates whether the keyPressReport API is available on the device which is
// dynamically set when the device responds to the first key press event or reports its
// keysDownState when queried since the keyPressReport was introduced together with the
// This flag indicates whether the keyPressReport API is available on the device which is
// dynamically set when the device responds to the first key press event or reports its // keysDownState when queried since the keyPressReport was introduced together with the
// getKeysDownState API.
// 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) => {
} = useHidRpc(message => {
switch (message.constructor) {
case KeysDownStateMessage:
setKeysDownState((message as KeysDownStateMessage).keysDownState);
@ -35,81 +73,80 @@ export default function useKeyboard() {
case KeyboardLedStateMessage:
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
break;
case KeyboardMacroStateMessage:
if (!(message as KeyboardMacroStateMessage).isPaste) break;
setPasteModeEnabled((message as KeyboardMacroStateMessage).state);
break;
default:
break;
}
});
// sendKeyboardEvent is used to send the full keyboard state to the device for macro handling
// and resetting keyboard state. It sends the keys currently pressed and the modifier state.
// The device will respond with the keysDownState if it supports the keyPressReport API
// or just accept the state if it does not support (returning no result)
const sendKeyboardEvent = useCallback(
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
if (rpcHidReady) {
console.debug("Sending keyboard report via HidRPC");
sendKeyboardEventHidRpc(state.keys, state.modifier);
return;
}
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
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);
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,
rpcHidReady,
send,
sendKeyboardEventHidRpc,
],
[send, setKeysDownState],
);
// 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]);
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"));
};
// 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);
ac?.signal?.addEventListener("abort", abortListener);
// 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));
send(
"keyboardReport",
{ keys, modifier },
params => {
if ("error" in params) return reject(params.error);
resolve();
},
);
});
}, [send]);
resetKeyboardState();
} else {
// This is a delay-only step, just wait for the delay amount
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
}
const KEEPALIVE_INTERVAL = 50;
// Add a small pause between steps if not the last step
if (index < steps.length - 1) {
await new Promise(resolve => setTimeout(resolve, 10));
}
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, 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.
@ -117,6 +154,20 @@ 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" && !rpcHidReady) return;
@ -129,11 +180,18 @@ export default function useKeyboard() {
// Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypressEventHidRpc(key, press);
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) {
@ -142,17 +200,21 @@ export default function useKeyboard() {
}
},
[
rpcDataChannel?.readyState,
rpcHidReady,
keysDownState,
handleLegacyKeyboardReport,
resetKeyboardState,
rpcDataChannel?.readyState,
sendKeyboardEvent,
sendKeypressEventHidRpc,
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
@ -164,7 +226,7 @@ export default function useKeyboard() {
if (modifierMask !== 0) {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if (press) {
modifiers |= modifierMask;
@ -181,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) {
@ -203,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 };
}

View File

@ -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,
};
}

View File

@ -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 {
@ -332,7 +332,7 @@ video::-webkit-media-controls {
.keyboard-detached .simple-keyboard.hg-theme-default div.hg-button {
text-wrap: auto;
text-align: center;
text-align: center;
min-width: 6ch;
}
.keyboard-detached .simple-keyboard.hg-theme-default .hg-button span {

View File

@ -3,12 +3,12 @@ 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(() => {

View File

@ -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");

View File

@ -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" },

View File

@ -19,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,
@ -42,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,
@ -51,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;
@ -136,6 +134,8 @@ export default function KvmIdRoute() {
rpcDataChannel,
setTransceiver,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
} = useRTCStore();
const location = useLocation();
@ -488,6 +488,24 @@ export default function KvmIdRoute() {
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,
@ -499,6 +517,8 @@ export default function KvmIdRoute() {
setPeerConnectionState,
setRpcDataChannel,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver,
]);
@ -583,6 +603,7 @@ export default function KvmIdRoute() {
keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState,
} = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@ -692,9 +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);
setHidRpcDisabled(true);
} else {
console.error("Failed to get key down state", resp.error);
}
@ -705,7 +727,7 @@ export default function KvmIdRoute() {
}
setNeedKeyDownState(false);
});
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, 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(() => {
@ -734,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 =

View File

@ -31,20 +31,36 @@ export default defineConfig(({ mode, command }) => {
esbuild: {
pure: ["console.debug"],
},
build: { outDir: isCloud ? "dist" : "../static" },
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" : "/",

12
usb.go
View File

@ -33,7 +33,13 @@ func initUsbGadget() {
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
if currentSession != nil {
currentSession.reportHidRPCKeysDownState(state)
currentSession.enqueueKeysDownState(state)
}
})
gadget.SetOnKeepAliveReset(func() {
if currentSession != nil {
currentSession.resetKeepAliveTime()
}
})
@ -43,11 +49,11 @@ func initUsbGadget() {
}
}
func rpcKeyboardReport(modifier byte, keys []byte) (usbgadget.KeysDownState, error) {
func rpcKeyboardReport(modifier byte, keys []byte) error {
return gadget.KeyboardReport(modifier, keys)
}
func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) {
func rpcKeypressReport(key byte, press bool) error {
return gadget.KeypressReport(key, press)
}

65
web.go
View File

@ -11,6 +11,7 @@ import (
"net/http"
"net/http/pprof"
"path/filepath"
"slices"
"strings"
"time"
@ -24,6 +25,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/vearutop/statigz"
"golang.org/x/crypto/bcrypt"
)
@ -66,6 +68,10 @@ type SetupRequest struct {
Password string `json:"password,omitempty"`
}
var cachableFileExtensions = []string{
".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2",
}
func setupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor()
@ -75,23 +81,47 @@ func setupRouter() *gin.Engine {
return *ginLogger
}),
))
staticFS, _ := fs.Sub(staticFiles, "static")
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory")
}
staticFileServer := http.StripPrefix("/static", statigz.FileServer(
staticFS.(fs.ReadDirFS),
))
// Add a custom middleware to set cache headers for images
// This is crucial for optimizing the initial welcome screen load time
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache
// This allows for a smoother enter animation and improved user experience on the welcome screen
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static/assets/immutable/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.Next()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
ext := filepath.Ext(c.Request.URL.Path)
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
if slices.Contains(cachableFileExtensions, ext) {
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
}
}
c.Next()
})
r.StaticFS("/static", http.FS(staticFS))
r.GET("/robots.txt", func(c *gin.Context) {
c.Header("Content-Type", "text/plain")
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.String(http.StatusOK, "User-agent: *\nDisallow: /")
})
r.Any("/static/*w", func(c *gin.Context) {
staticFileServer.ServeHTTP(c.Writer, c.Request)
})
// Public routes (no authentication required)
r.POST("/auth/login-local", handleLogin)
// We use this to determine if the device is setup
@ -198,6 +228,10 @@ func handleWebRTCSession(c *gin.Context) {
_ = peerConn.Close()
}()
}
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd})
}
@ -532,14 +566,31 @@ func RunWebServer() {
r := setupRouter()
// Determine the binding address based on the config
bindAddress := ":80" // Default to all interfaces
var bindAddress string
listenPort := 80 // default port
useIPv4 := config.NetworkConfig.IPv4Mode.String != "disabled"
useIPv6 := config.NetworkConfig.IPv6Mode.String != "disabled"
if config.LocalLoopbackOnly {
bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf("localhost:%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("127.0.0.1:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::1]:%d", listenPort)
}
} else {
if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf(":%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("0.0.0.0:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::]:%d", listenPort)
}
}
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
err := r.Run(bindAddress)
if err != nil {
if err := r.Run(bindAddress); err != nil {
panic(err)
}
}

146
webrtc.go
View File

@ -7,12 +7,14 @@ import (
"net"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/gin-gonic/gin"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
)
@ -27,9 +29,26 @@ type Session struct {
rpcQueue chan webrtc.DataChannelMessage
hidRPCAvailable bool
hidQueueLock sync.Mutex
hidQueue []chan webrtc.DataChannelMessage
hidRPCAvailable bool
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
lastTimerResetTime time.Time // Track when auto-release timer was last reset
keepAliveJitterLock sync.Mutex // Protect jitter compensation timing state
hidQueueLock sync.Mutex
hidQueue []chan hidQueueMessage
keysDownStateQueue chan usbgadget.KeysDownState
}
func (s *Session) resetKeepAliveTime() {
s.keepAliveJitterLock.Lock()
defer s.keepAliveJitterLock.Unlock()
s.lastKeepAliveArrivalTime = time.Time{} // Reset keep-alive timing tracking
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
}
type hidQueueMessage struct {
webrtc.DataChannelMessage
channel string
}
type SessionConfig struct {
@ -78,16 +97,85 @@ func (s *Session) initQueues() {
s.hidQueueLock.Lock()
defer s.hidQueueLock.Unlock()
s.hidQueue = make([]chan webrtc.DataChannelMessage, 0)
s.hidQueue = make([]chan hidQueueMessage, 0)
for i := 0; i < 4; i++ {
q := make(chan webrtc.DataChannelMessage, 256)
q := make(chan hidQueueMessage, 256)
s.hidQueue = append(s.hidQueue, q)
}
}
func (s *Session) handleQueues(index int) {
for msg := range s.hidQueue[index] {
onHidMessage(msg.Data, s)
onHidMessage(msg, s)
}
}
const keysDownStateQueueSize = 64
func (s *Session) initKeysDownStateQueue() {
// serialise outbound key state reports so unreliable links can't stall input handling
s.keysDownStateQueue = make(chan usbgadget.KeysDownState, keysDownStateQueueSize)
go s.handleKeysDownStateQueue()
}
func (s *Session) handleKeysDownStateQueue() {
for state := range s.keysDownStateQueue {
s.reportHidRPCKeysDownState(state)
}
}
func (s *Session) enqueueKeysDownState(state usbgadget.KeysDownState) {
if s == nil || s.keysDownStateQueue == nil {
return
}
select {
case s.keysDownStateQueue <- state:
default:
hidRPCLogger.Warn().Msg("dropping keys down state update; queue full")
}
}
func getOnHidMessageHandler(session *Session, scopedLogger *zerolog.Logger, channel string) func(msg webrtc.DataChannelMessage) {
return func(msg webrtc.DataChannelMessage) {
l := scopedLogger.With().
Str("channel", channel).
Int("length", len(msg.Data)).
Logger()
// only log data if the log level is debug or lower
if scopedLogger.GetLevel() > zerolog.DebugLevel {
l = l.With().Str("data", string(msg.Data)).Logger()
}
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- hidQueueMessage{
DataChannelMessage: msg,
channel: channel,
}
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
}
}
@ -133,6 +221,7 @@ func newSession(config SessionConfig) (*Session, error) {
session := &Session{peerConnection: peerConnection}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues()
session.initKeysDownStateQueue()
go func() {
for msg := range session.rpcQueue {
@ -157,40 +246,12 @@ func newSession(config SessionConfig) (*Session, error) {
switch d.Label() {
case "hidrpc":
session.HidChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {
l := scopedLogger.With().Int("length", len(msg.Data)).Logger()
// only log data if the log level is debug or lower
if scopedLogger.GetLevel() > zerolog.DebugLevel {
l = l.With().Str("data", string(msg.Data)).Logger()
}
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- msg
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
})
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc"))
// we won't send anything over the unreliable channels
case "hidrpc-unreliable-ordered":
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-ordered"))
case "hidrpc-unreliable-nonordered":
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-nonordered"))
case "rpc":
session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {
@ -266,6 +327,8 @@ func newSession(config SessionConfig) (*Session, error) {
if connectionState == webrtc.ICEConnectionStateClosed {
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
if session == currentSession {
// Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro()
currentSession = nil
}
// Stop RPC processor
@ -280,6 +343,9 @@ func newSession(config SessionConfig) (*Session, error) {
session.hidQueue[i] = nil
}
close(session.keysDownStateQueue)
session.keysDownStateQueue = nil
if session.shouldUmountVirtualMedia {
if err := rpcUnmountImage(); err != nil {
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")