Compare commits

...

7 Commits

Author SHA1 Message Date
Aveline 5ec9716877
Merge 024cbb8fb1 into c8dd84c6b7 2025-09-10 14:57:40 +00:00
Siyuan Miao 024cbb8fb1 wip: send macro using hidRPC channel 2025-09-10 16:57:16 +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
Adam Shiervani c98592a412
feat(ui): Enhance EDID settings with loading state (#691)
* feat(ui): Enhance EDID settings with loading state and Fieldset component

* fix(ui): Improve notifications and adjust styling in custom EDID component

* fix(ui): specify JsonRpcResponse type
2025-09-08 11:38:49 +02:00
Marc Brooks 8fbad0112e
fix(ui): Don't render a button in a button (#782)
Gets rid of warning at initial page load.
2025-09-08 11:06:08 +02:00
Claus Holst 8a90555fad
Update URL Mount entries for Ubuntu, Fedora and Arch Linux (#783) 2025-09-08 11:02:46 +02:00
Adam Shiervani a7db0e8408
Enhance connection stats sidebar (#748)
* feat: add Metric component for data visualization

* refactor: update ConnectionStatsSidebar to use Metric component for improved data visualization

* feat: add someIterable utility function and update Metric components for consistent metric handling

- Introduced `someIterable` function to check for the presence of a metric in an iterable.
- Updated `CustomTooltip` and `Metric` components to use `metric` instead of `stat` for improved clarity.
- Refactored `StatChart` to align with the new metric naming convention.

* refactor: rename variable for clarity in Metric component

* docs: add JSDoc comments to createChartArray function in Metric component for better documentation

* feat: do an actual avg reference calc

* feat: Dont collect stats without a video track

* refactor: rename variables for clarity
2025-09-08 10:59:36 +02:00
20 changed files with 783 additions and 458 deletions

View File

@ -118,6 +118,7 @@ var defaultConfig = &Config{
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI // This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{ JigglerConfig: &JigglerConfig{
InactivityLimitSeconds: 60, InactivityLimitSeconds: 60,
@ -205,6 +206,15 @@ func LoadConfig() {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig 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 config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
@ -221,6 +231,11 @@ func SaveConfig() error {
logger.Trace().Str("path", configPath).Msg("Saving config") 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) file, err := os.Create(configPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create config file: %w", err) 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) 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 return nil
} }

View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -31,6 +32,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
session.reportHidRPCKeysDownState(*keysDownState) session.reportHidRPCKeysDownState(*keysDownState)
} }
rpcErr = err rpcErr = err
case hidrpc.TypeKeyboardMacroReport:
keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return
}
_, rpcErr = rpcKeyboardReportMulti(context.Background(), keyboardMacroReport.Macro)
case hidrpc.TypePointerReport: case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport() pointerReport, err := message.PointerReport()
if err != nil { if err != nil {

View File

@ -10,14 +10,17 @@ import (
type MessageType byte type MessageType byte
const ( const (
TypeHandshake MessageType = 0x01 TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02 TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03 TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04 TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05 TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06 TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32 TypeKeyboardMacroReport MessageType = 0x07
TypeKeydownState MessageType = 0x33 TypeCancelKeyboardMacroReport MessageType = 0x08
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
TypeKeyboardMacroStateReport MessageType = 0x34
) )
const ( const (
@ -29,7 +32,7 @@ func GetQueueIndex(messageType MessageType) int {
switch messageType { switch messageType {
case TypeHandshake: case TypeHandshake:
return 0 return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState: case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroStateReport:
return 1 return 1
case TypePointerReport, TypeMouseReport, TypeWheelReport: case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2 return 2

View File

@ -1,6 +1,7 @@
package hidrpc package hidrpc
import ( import (
"encoding/binary"
"fmt" "fmt"
) )
@ -43,6 +44,11 @@ func (m *Message) String() string {
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d) return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
} }
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2]) return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeyboardMacroReport:
if len(m.d) < 5 {
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
default: default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d) return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
} }
@ -84,6 +90,51 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
}, nil }, nil
} }
// Macro ..
type KeyboardMacro struct {
Modifier byte // 1 byte
Keys []byte // 6 bytes, to make things easier, the keys length is fixed to 6
Delay uint16 // 2 bytes
}
type KeyboardMacroReport struct {
IsPaste bool
Length uint32
Macro []KeyboardMacro
}
// KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
if m.t != TypeKeyboardMacroReport {
return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
isPaste := m.d[0] == uint8(1)
length := binary.BigEndian.Uint32(m.d[1:5])
// check total length
expectedLength := int(length)*9 + 5
if len(m.d) != expectedLength {
return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength)
}
macro := make([]KeyboardMacro, 0, int(length))
for i := 0; i < int(length); i++ {
offset := 5 + i*9
macro = append(macro, KeyboardMacro{
Modifier: m.d[offset],
Keys: m.d[offset+1 : offset+7],
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
})
}
return KeyboardMacroReport{
IsPaste: isPaste,
Macro: macro,
Length: length,
}, nil
}
// PointerReport .. // PointerReport ..
type PointerReport struct { type PointerReport struct {
X int X int

View File

@ -17,16 +17,20 @@ type JigglerConfig struct {
Timezone string `json:"timezone,omitempty"` Timezone string `json:"timezone,omitempty"`
} }
var jigglerEnabled = false
var jobDelta time.Duration = 0 var jobDelta time.Duration = 0
var scheduler gocron.Scheduler = nil var scheduler gocron.Scheduler = nil
func rpcSetJigglerState(enabled bool) { func rpcSetJigglerState(enabled bool) error {
jigglerEnabled = enabled config.JigglerEnabled = enabled
err := SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
} }
func rpcGetJigglerState() bool { func rpcGetJigglerState() bool {
return jigglerEnabled return config.JigglerEnabled
} }
func rpcGetTimezones() []string { func rpcGetTimezones() []string {
@ -118,7 +122,7 @@ func runJigglerCronTab() error {
} }
func runJiggler() { func runJiggler() {
if jigglerEnabled { if config.JigglerEnabled {
if config.JigglerConfig.JitterPercentage != 0 { if config.JigglerConfig.JitterPercentage != 0 {
jitter := calculateJitterDuration(jobDelta) jitter := calculateJitterDuration(jobDelta)
time.Sleep(jitter) time.Sleep(jitter)

View File

@ -10,13 +10,13 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
@ -1050,52 +1050,56 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil return nil
} }
// cancelKeyboardReportMulti cancels any ongoing keyboard report multi execution
func cancelKeyboardReportMulti() { func cancelKeyboardReportMulti() {
keyboardReportMultiLock.Lock()
defer keyboardReportMultiLock.Unlock()
if keyboardReportMultiCancel != nil {
keyboardReportMultiCancel()
logger.Info().Msg("canceled keyboard report multi")
keyboardReportMultiCancel = nil
}
} }
func setKeyboardReportMultiCancel(cancel context.CancelFunc) { // // cancelKeyboardReportMulti cancels any ongoing keyboard report multi execution
keyboardReportMultiLock.Lock() // func cancelKeyboardReportMulti() {
defer keyboardReportMultiLock.Unlock() // keyboardReportMultiLock.Lock()
// defer keyboardReportMultiLock.Unlock()
keyboardReportMultiCancel = cancel // if keyboardReportMultiCancel != nil {
} // keyboardReportMultiCancel()
// logger.Info().Msg("canceled keyboard report multi")
// keyboardReportMultiCancel = nil
// }
// }
func rpcKeyboardReportMultiWrapper(macro []map[string]any) (usbgadget.KeysDownState, error) { // func setKeyboardReportMultiCancel(cancel context.CancelFunc) {
cancelKeyboardReportMulti() // keyboardReportMultiLock.Lock()
// defer keyboardReportMultiLock.Unlock()
ctx, cancel := context.WithCancel(context.Background()) // keyboardReportMultiCancel = cancel
setKeyboardReportMultiCancel(cancel) // }
writeJSONRPCEvent("keyboardReportMultiState", true, currentSession) // func rpcKeyboardReportMultiWrapper(macro []map[string]any) (usbgadget.KeysDownState, error) {
// // cancelKeyboardReportMulti()
result, err := rpcKeyboardReportMulti(ctx, macro) // // ctx, cancel := context.WithCancel(context.Background())
// // setKeyboardReportMultiCancel(cancel)
setKeyboardReportMultiCancel(nil) // // writeJSONRPCEvent("keyboardReportMultiState", true, currentSession)
writeJSONRPCEvent("keyboardReportMultiState", false, currentSession) // // result, err := rpcKeyboardReportMulti(ctx, macro)
return result, err // // setKeyboardReportMultiCancel(nil)
}
var ( // // writeJSONRPCEvent("keyboardReportMultiState", false, currentSession)
keyboardReportMultiCancel context.CancelFunc
keyboardReportMultiLock sync.Mutex
)
func rpcCancelKeyboardReportMulti() { // // return result, err
cancelKeyboardReportMulti() // }
}
func rpcKeyboardReportMulti(ctx context.Context, macro []map[string]any) (usbgadget.KeysDownState, error) { // var (
// keyboardReportMultiCancel context.CancelFunc
// keyboardReportMultiLock sync.Mutex
// )
// func rpcCancelKeyboardReportMulti() {
// cancelKeyboardReportMulti()
// }
func rpcKeyboardReportMulti(ctx context.Context, macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
var last usbgadget.KeysDownState var last usbgadget.KeysDownState
var err error var err error
@ -1110,134 +1114,112 @@ func rpcKeyboardReportMulti(ctx context.Context, macro []map[string]any) (usbgad
default: default:
} }
var modifier byte delay := time.Duration(step.Delay) * time.Millisecond
if m, ok := step["modifier"].(float64); ok { logger.Info().Int("step", i).Uint16("delay", step.Delay).Msg("Keyboard report multi delay")
modifier = byte(int(m))
} else if mi, ok := step["modifier"].(int); ok {
modifier = byte(mi)
} else if mb, ok := step["modifier"].(uint8); ok {
modifier = mb
}
var keys []byte last, err = rpcKeyboardReport(step.Modifier, step.Keys)
if arr, ok := step["keys"].([]any); ok { if err != nil {
keys = make([]byte, 0, len(arr)) logger.Warn().Err(err).Msg("failed to execute keyboard report multi")
for _, v := range arr { return last, err
if f, ok := v.(float64); ok {
keys = append(keys, byte(int(f)))
} else if i, ok := v.(int); ok {
keys = append(keys, byte(i))
} else if b, ok := v.(uint8); ok {
keys = append(keys, b)
}
}
} else if bs, ok := step["keys"].([]byte); ok {
keys = bs
} }
// Use context-aware sleep that can be cancelled // Use context-aware sleep that can be cancelled
select { select {
case <-time.After(100 * time.Millisecond): case <-time.After(delay):
// Sleep completed normally // Sleep completed normally
case <-ctx.Done(): case <-ctx.Done():
logger.Debug().Int("step", i).Msg("Keyboard report multi cancelled during sleep") logger.Debug().Int("step", i).Msg("Keyboard report multi cancelled during sleep")
return last, ctx.Err() return last, ctx.Err()
} }
last, err = rpcKeyboardReport(modifier, keys)
if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard report multi")
return last, err
}
} }
return last, nil return last, nil
} }
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}}, "reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice}, "deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState}, "getCloudState": {Func: rpcGetCloudState},
"getNetworkState": {Func: rpcGetNetworkState}, "getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings}, "getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"keyboardReportMulti": {Func: rpcKeyboardReportMultiWrapper, Params: []string{"macro"}}, // "keyboardReportMulti": {Func: rpcKeyboardReportMultiWrapper, Params: []string{"macro"}},
"cancelKeyboardReportMulti": {Func: rpcCancelKeyboardReportMulti}, // "cancelKeyboardReportMulti": {Func: rpcCancelKeyboardReportMulti},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState}, "getKeyDownState": {Func: rpcGetKeysDownState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState}, "getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState}, "getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage}, "unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState}, "getJigglerState": {Func: rpcGetJigglerState},
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
"getJigglerConfig": {Func: rpcGetJigglerConfig}, "getJigglerConfig": {Func: rpcGetJigglerConfig},
"getTimezones": {Func: rpcGetTimezones}, "getTimezones": {Func: rpcGetTimezones},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState}, "getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}}, "setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID}, "getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState}, "getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState}, "getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode}, "getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending}, "isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig}, "getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace}, "getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles}, "listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, "deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}}, "startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig}, "resetConfig": {Func: rpcResetConfig},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}}, "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation}, "getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings}, "getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState}, "getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}}, "setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension}, "getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState}, "getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getUsbDevices": {Func: rpcGetUsbDevices}, "getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout}, "getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros}, "getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
} }

View File

@ -1,25 +1,25 @@
import Card from "@components/Card"; import Card from "@components/Card";
export interface CustomTooltipProps { export interface CustomTooltipProps {
payload: { payload: { date: number; stat: number }; unit: string }[]; payload: { payload: { date: number; metric: number }; unit: string }[];
} }
export default function CustomTooltip({ payload }: CustomTooltipProps) { export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) { if (payload?.length) {
const toolTipData = payload[0]; const toolTipData = payload[0];
const { date, stat } = toolTipData.payload; const { date, metric } = toolTipData.payload;
return ( return (
<Card> <Card>
<div className="p-2 text-black dark:text-white"> <div className="px-2 py-1.5 text-black dark:text-white">
<div className="font-semibold"> <div className="text-[13px] font-semibold">
{new Date(date * 1000).toLocaleTimeString()} {new Date(date * 1000).toLocaleTimeString()}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" /> <div className="h-[2px] w-2 bg-blue-700" />
<span > <span className="text-[13px]">
{stat} {toolTipData?.unit} {metric} {toolTipData?.unit}
</span> </span>
</div> </div>
</div> </div>

View File

@ -103,7 +103,7 @@ export default function DashboardNavbar({
<hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" /> <hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
<div className="relative inline-block text-left"> <div className="relative inline-block text-left">
<Menu> <Menu>
<MenuButton className="h-full"> <MenuButton as="div" className="h-full">
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white"> <Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
{picture ? ( {picture ? (
<img <img

View File

@ -100,15 +100,12 @@ export default function KvmCard({
)} )}
</div> </div>
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
<div> <MenuButton
<MenuButton as={Button}
as={Button} theme="light"
theme="light" TrailingIcon={LuEllipsisVertical}
TrailingIcon={LuEllipsisVertical} size="MD"
size="MD" ></MenuButton>
></MenuButton>
</div>
<MenuItems <MenuItems
transition transition
className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in" className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"

View File

@ -0,0 +1,180 @@
/* eslint-disable react-refresh/only-export-components */
import { ComponentProps } from "react";
import { cva, cx } from "cva";
import { someIterable } from "../utils";
import { GridCard } from "./Card";
import MetricsChart from "./MetricsChart";
interface ChartPoint {
date: number;
metric: number | null;
}
interface MetricProps<T, K extends keyof T> {
title: string;
description: string;
stream?: Map<number, T>;
metric?: K;
data?: ChartPoint[];
gate?: Map<number, unknown>;
supported?: boolean;
map?: (p: { date: number; metric: number | null }) => ChartPoint;
domain?: [number, number];
unit: string;
heightClassName?: string;
referenceValue?: number;
badge?: ComponentProps<typeof MetricHeader>["badge"];
badgeTheme?: ComponentProps<typeof MetricHeader>["badgeTheme"];
}
/**
* Creates a chart array from a metrics map and a metric name.
*
* @param metrics - Expected to be ordered from oldest to newest.
* @param metricName - Name of the metric to create a chart array for.
*/
export function createChartArray<T, K extends keyof T>(
metrics: Map<number, T>,
metricName: K,
) {
const result: { date: number; metric: number | null }[] = [];
const iter = metrics.entries();
let next = iter.next() as IteratorResult<[number, T]>;
const nowSeconds = Math.floor(Date.now() / 1000);
// We want 120 data points, in the chart.
const firstDate = Math.min(next.value?.[0] ?? nowSeconds, nowSeconds - 120);
for (let t = firstDate; t < nowSeconds; t++) {
while (!next.done && next.value[0] < t) next = iter.next();
const has = !next.done && next.value[0] === t;
let metric = null;
if (has) metric = next.value[1][metricName] as number;
result.push({ date: t, metric });
if (has) next = iter.next();
}
return result;
}
function computeReferenceValue(points: ChartPoint[]): number | undefined {
const values = points
.filter(p => p.metric != null && Number.isFinite(p.metric))
.map(p => Number(p.metric));
if (values.length === 0) return undefined;
const sum = values.reduce((acc, v) => acc + v, 0);
const mean = sum / values.length;
return Math.round(mean);
}
const theme = {
light:
"bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300",
danger: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50",
primary: "bg-blue-500 dark:border-blue-700 dark:bg-blue-800 dark:text-blue-50",
};
interface SettingsItemProps {
readonly title: string;
readonly description: string | React.ReactNode;
readonly badge?: string;
readonly className?: string;
readonly children?: React.ReactNode;
readonly badgeTheme?: keyof typeof theme;
}
export function MetricHeader(props: SettingsItemProps) {
const { title, description, badge } = props;
const badgeVariants = cva({ variants: { theme: theme } });
return (
<div className="space-y-0.5">
<div className="flex items-center gap-x-2">
<div className="flex w-full items-center justify-between text-base font-semibold text-black dark:text-white">
{title}
{badge && (
<span
className={cx(
"ml-2 rounded-sm px-2 py-1 font-mono text-[10px] leading-none font-medium",
badgeVariants({ theme: props.badgeTheme ?? "light" }),
)}
>
{badge}
</span>
)}
</div>
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
</div>
);
}
export function Metric<T, K extends keyof T>({
title,
description,
stream,
metric,
data,
gate,
supported,
map,
domain = [0, 600],
unit = "",
heightClassName = "h-[127px]",
badge,
badgeTheme,
}: MetricProps<T, K>) {
const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true;
const supportedFinal =
supported ??
(stream && metric ? someIterable(stream, ([, s]) => s[metric] !== undefined) : true);
// Either we let the consumer provide their own chartArray, or we create one from the stream and metric.
const raw = data ?? ((stream && metric && createChartArray(stream, metric)) || []);
// If the consumer provides a map function, we apply it to the raw data.
const dataFinal: ChartPoint[] = map ? raw.map(map) : raw;
// Compute the average value of the metric.
const referenceValue = computeReferenceValue(dataFinal);
return (
<div className="space-y-2">
<MetricHeader
title={title}
description={description}
badge={badge}
badgeTheme={badgeTheme}
/>
<GridCard>
<div
className={`flex ${heightClassName} w-full items-center justify-center text-sm text-slate-500`}
>
{!ready ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : supportedFinal ? (
<MetricsChart
data={dataFinal}
domain={domain}
unit={unit}
referenceValue={referenceValue}
/>
) : (
<div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
);
}

View File

@ -12,13 +12,13 @@ import {
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({ export default function MetricsChart({
data, data,
domain, domain,
unit, unit,
referenceValue, referenceValue,
}: { }: {
data: { date: number; stat: number | null | undefined }[]; data: { date: number; metric: number | null | undefined }[];
domain?: [string | number, string | number]; domain?: [string | number, string | number];
unit?: string; unit?: string;
referenceValue?: number; referenceValue?: number;
@ -33,7 +33,7 @@ export default function StatChart({
strokeLinecap="butt" strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)" stroke="rgba(30, 41, 59, 0.1)"
/> />
{referenceValue && ( {referenceValue !== undefined && (
<ReferenceLine <ReferenceLine
y={referenceValue} y={referenceValue}
strokeDasharray="3 3" strokeDasharray="3 3"
@ -64,7 +64,7 @@ export default function StatChart({
.map(x => x.date)} .map(x => x.date)}
/> />
<YAxis <YAxis
dataKey="stat" dataKey="metric"
axisLine={false} axisLine={false}
orientation="right" orientation="right"
tick={{ tick={{
@ -73,6 +73,7 @@ export default function StatChart({
fill: "rgba(107, 114, 128, 1)", fill: "rgba(107, 114, 128, 1)",
}} }}
padding={{ top: 0, bottom: 0 }} padding={{ top: 0, bottom: 0 }}
allowDecimals
tickLine={false} tickLine={false}
unit={unit} unit={unit}
domain={domain || ["auto", "auto"]} domain={domain || ["auto", "auto"]}
@ -87,7 +88,7 @@ export default function StatChart({
<Line <Line
type="monotone" type="monotone"
isAnimationActive={false} isAnimationActive={false}
dataKey="stat" dataKey="metric"
stroke="rgb(29 78 216)" stroke="rgb(29 78 216)"
strokeLinecap="round" strokeLinecap="round"
strokeWidth={2} strokeWidth={2}

View File

@ -1,74 +1,40 @@
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SidebarHeader from "@/components/SidebarHeader"; import SidebarHeader from "@/components/SidebarHeader";
import { GridCard } from "@/components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@/components/StatChart"; import { someIterable } from "@/utils";
function createChartArray<T, K extends keyof T>( import { createChartArray, Metric } from "../Metric";
stream: Map<number, T>, import { SettingsSectionHeader } from "../SettingsSectionHeader";
metric: K,
): { date: number; stat: T[K] | null }[] {
const stat = Array.from(stream).map(([key, stats]) => {
return { date: key, stat: stats[metric] };
});
// Sort the dates to ensure they are in chronological order
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
// Determine the earliest statistic date
const earliestStat = sortedStat[0];
// Current time in seconds since the Unix epoch
const now = Math.floor(Date.now() / 1000);
// Determine the starting point for the chart data
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
// Generate the chart array for the range between 'firstChartDate' and 'now'
return Array.from({ length: now - firstChartDate }, (_, i) => {
const currentDate = firstChartDate + i;
return {
date: currentDate,
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
};
});
}
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const { sidebarView, setSidebarView } = useUiStore(); const { sidebarView, setSidebarView } = useUiStore();
const { const {
mediaStream, mediaStream,
peerConnection, peerConnection,
inboundRtpStats, inboundRtpStats: inboundVideoRtpStats,
appendInboundRtpStats, appendInboundRtpStats: appendInboundVideoRtpStats,
candidatePairStats, candidatePairStats: iceCandidatePairStats,
appendCandidatePairStats, appendCandidatePairStats,
appendLocalCandidateStats, appendLocalCandidateStats,
appendRemoteCandidateStats, appendRemoteCandidateStats,
appendDiskDataChannelStats, appendDiskDataChannelStats,
} = useRTCStore(); } = useRTCStore();
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): boolean {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
useInterval(function collectWebRTCStats() { useInterval(function collectWebRTCStats() {
(async () => { (async () => {
if (!mediaStream) return; if (!mediaStream) return;
const videoTrack = mediaStream.getVideoTracks()[0]; const videoTrack = mediaStream.getVideoTracks()[0];
if (!videoTrack) return; if (!videoTrack) return;
const stats = await peerConnection?.getStats(); const stats = await peerConnection?.getStats();
let successfulLocalCandidateId: string | null = null; let successfulLocalCandidateId: string | null = null;
let successfulRemoteCandidateId: string | null = null; let successfulRemoteCandidateId: string | null = null;
stats?.forEach(report => { stats?.forEach(report => {
if (report.type === "inbound-rtp") { if (report.type === "inbound-rtp" && report.kind === "video") {
appendInboundRtpStats(report); appendInboundVideoRtpStats(report);
} else if (report.type === "candidate-pair" && report.nominated) { } else if (report.type === "candidate-pair" && report.nominated) {
if (report.state === "succeeded") { if (report.state === "succeeded") {
successfulLocalCandidateId = report.localCandidateId; successfulLocalCandidateId = report.localCandidateId;
@ -91,144 +57,133 @@ export default function ConnectionStatsSidebar() {
})(); })();
}, 500); }, 500);
const jitterBufferDelay = createChartArray(inboundVideoRtpStats, "jitterBufferDelay");
const jitterBufferEmittedCount = createChartArray(
inboundVideoRtpStats,
"jitterBufferEmittedCount",
);
const jitterBufferAvgDelayData = jitterBufferDelay.map((d, idx) => {
if (idx === 0) return { date: d.date, metric: null };
const prevDelay = jitterBufferDelay[idx - 1]?.metric as number | null | undefined;
const currDelay = d.metric as number | null | undefined;
const prevCountEmitted =
(jitterBufferEmittedCount[idx - 1]?.metric as number | null | undefined) ?? null;
const currCountEmitted =
(jitterBufferEmittedCount[idx]?.metric as number | null | undefined) ?? null;
if (
prevDelay == null ||
currDelay == null ||
prevCountEmitted == null ||
currCountEmitted == null
) {
return { date: d.date, metric: null };
}
const deltaDelay = currDelay - prevDelay;
const deltaEmitted = currCountEmitted - prevCountEmitted;
// Guard counter resets or no emitted frames
if (deltaDelay < 0 || deltaEmitted <= 0) {
return { date: d.date, metric: null };
}
const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000);
return { date: d.date, metric: valueMs };
});
return ( return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
*/}
{sidebarView === "connection-stats" && ( {sidebarView === "connection-stats" && (
<div className="space-y-4"> <div className="space-y-8">
<div className="space-y-2"> {/* Connection Group */}
<div> <div className="space-y-3">
<h2 className="text-lg font-semibold text-black dark:text-white"> <SettingsSectionHeader
Packets Lost title="Connection"
</h2> description="The connection between the client and the JetKVM."
<p className="text-sm text-slate-700 dark:text-slate-300"> />
Number of data packets lost during transmission. <Metric
</p> title="Round-Trip Time"
</div> description="Round-trip time for the active ICE candidate pair between peers."
<GridCard> stream={iceCandidatePairStats}
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> metric="currentRoundTripTime"
{inboundRtpStats.size === 0 ? ( map={x => ({
<div className="flex flex-col items-center space-y-1"> date: x.date,
<p className="text-slate-700">Waiting for data...</p> metric: x.metric != null ? Math.round(x.metric * 1000) : null,
</div> })}
) : isMetricSupported(inboundRtpStats, "packetsLost") ? ( domain={[0, 600]}
<StatChart unit=" ms"
data={createChartArray(inboundRtpStats, "packetsLost")} />
domain={[0, 100]}
unit=" packets"
/>
) : (
<div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div> </div>
<div className="space-y-2">
<div> {/* Video Group */}
<h2 className="text-lg font-semibold text-black dark:text-white"> <div className="space-y-3">
Round-Trip Time <SettingsSectionHeader
</h2> title="Video"
<p className="text-sm text-slate-700 dark:text-slate-300"> description="The video stream from the JetKVM to the client."
Time taken for data to travel from source to destination and back />
</p>
</div> {/* RTP Jitter */}
<GridCard> <Metric
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> title="Network Stability"
{inboundRtpStats.size === 0 ? ( badge="Jitter"
<div className="flex flex-col items-center space-y-1"> badgeTheme="light"
<p className="text-slate-700">Waiting for data...</p> description="How steady the flow of inbound video packets is across the network."
</div> stream={inboundVideoRtpStats}
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? ( metric="jitter"
<StatChart map={x => ({
data={createChartArray( date: x.date,
candidatePairStats, metric: x.metric != null ? Math.round(x.metric * 1000) : null,
"currentRoundTripTime", })}
).map(x => { domain={[0, 10]}
return { unit=" ms"
date: x.date, />
stat: x.stat ? Math.round(x.stat * 1000) : null,
}; {/* Playback Delay */}
})} <Metric
domain={[0, 600]} title="Playback Delay"
unit=" ms" description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly."
/> badge="Jitter Buffer Avg. Delay"
) : ( badgeTheme="light"
<div className="flex flex-col items-center space-y-1"> data={jitterBufferAvgDelayData}
<p className="text-black">Metric not supported</p> gate={inboundVideoRtpStats}
</div> supported={
)} someIterable(
</div> inboundVideoRtpStats,
</GridCard> ([, x]) => x.jitterBufferDelay != null,
</div> ) &&
<div className="space-y-2"> someIterable(
<div> inboundVideoRtpStats,
<h2 className="text-lg font-semibold text-black dark:text-white"> ([, x]) => x.jitterBufferEmittedCount != null,
Jitter )
</h2> }
<p className="text-sm text-slate-700 dark:text-slate-300"> domain={[0, 30]}
Variation in packet delay, affecting video smoothness.{" "} unit=" ms"
</p> />
</div>
<GridCard> {/* Packets Lost */}
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> <Metric
{inboundRtpStats.size === 0 ? ( title="Packets Lost"
<div className="flex flex-col items-center space-y-1"> description="Count of lost inbound video RTP packets."
<p className="text-slate-700">Waiting for data...</p> stream={inboundVideoRtpStats}
</div> metric="packetsLost"
) : ( domain={[0, 100]}
<StatChart unit=" packets"
data={createChartArray(inboundRtpStats, "jitter").map(x => { />
return {
date: x.date, {/* Frames Per Second */}
stat: x.stat ? Math.round(x.stat * 1000) : null, <Metric
}; title="Frames per second"
})} description="Number of inbound video frames displayed per second."
domain={[0, 300]} stream={inboundVideoRtpStats}
unit=" ms" metric="framesPerSecond"
/> domain={[0, 80]}
)} unit=" fps"
</div> />
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Frames per second
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of video frames displayed per second.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
x => {
return {
date: x.date,
stat: x.stat ? x.stat : null,
};
},
)}
domain={[0, 80]}
unit=" fps"
/>
)}
</div>
</GridCard>
</div> </div>
</div> </div>
)} )}

View File

@ -7,6 +7,7 @@ export const HID_RPC_MESSAGE_TYPES = {
WheelReport: 0x04, WheelReport: 0x04,
KeypressReport: 0x05, KeypressReport: 0x05,
MouseReport: 0x06, MouseReport: 0x06,
KeyboardMacroReport: 0x07,
KeyboardLedState: 0x32, KeyboardLedState: 0x32,
KeysDownState: 0x33, KeysDownState: 0x33,
} }
@ -32,6 +33,30 @@ const fromInt32toUint8 = (n: number) => {
]); ]);
}; };
const fromUint16toUint8 = (n: number) => {
if (n > 65535 || n < 0) {
throw new Error(`Number ${n} is not within the uint16 range`);
}
return new Uint8Array([
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromUint32toUint8 = (n: number) => {
if (n > 4294967295 || n < 0) {
throw new Error(`Number ${n} is not within the uint32 range`);
}
return new Uint8Array([
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromInt8ToUint8 = (n: number) => { const fromInt8ToUint8 = (n: number) => {
if (n < -128 || n > 127) { if (n < -128 || n > 127) {
throw new Error(`Number ${n} is not within the int8 range`); throw new Error(`Number ${n} is not within the int8 range`);
@ -186,6 +211,64 @@ export class KeyboardReportMessage extends RpcMessage {
} }
} }
export interface KeyboardMacro extends KeysDownState {
delay: number;
}
export class KeyboardMacroReportMessage extends RpcMessage {
isPaste: boolean;
length: number;
macro: KeyboardMacro[];
KEYS_LENGTH = 6;
constructor(isPaste: boolean, length: number, macro: KeyboardMacro[]) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport);
this.isPaste = isPaste;
this.length = length;
this.macro = macro;
}
marshal(): Uint8Array {
const dataHeader = new Uint8Array([
this.messageType,
this.isPaste ? 1 : 0,
...fromUint32toUint8(this.length),
]);
let dataBody = new Uint8Array();
for (const step of this.macro) {
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),
]);
dataBody = new Uint8Array([...dataBody, ...macroBinary]);
}
return new Uint8Array([...dataHeader, ...dataBody]);
}
}
export class KeyboardLedStateMessage extends RpcMessage { export class KeyboardLedStateMessage extends RpcMessage {
keyboardLedState: KeyboardLedState; keyboardLedState: KeyboardLedState;

View File

@ -5,6 +5,8 @@ import { useRTCStore } from "@/hooks/stores";
import { import {
HID_RPC_VERSION, HID_RPC_VERSION,
HandshakeMessage, HandshakeMessage,
KeyboardMacro,
KeyboardMacroReportMessage,
KeyboardReportMessage, KeyboardReportMessage,
KeypressReportMessage, KeypressReportMessage,
MouseReportMessage, MouseReportMessage,
@ -68,6 +70,15 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[sendMessage], [sendMessage],
); );
const reportKeyboardMacroEvent = useCallback(
(macro: KeyboardMacro[]) => {
const d = new KeyboardMacroReportMessage(false, macro.length, macro);
sendMessage(d);
console.log("Sent keyboard macro report", d, d.marshal());
},
[sendMessage],
);
const sendHandshake = useCallback(() => { const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return; if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
@ -143,6 +154,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
reportKeypressEvent, reportKeypressEvent,
reportAbsMouseEvent, reportAbsMouseEvent,
reportRelMouseEvent, reportRelMouseEvent,
reportKeyboardMacroEvent,
rpcHidProtocolVersion, rpcHidProtocolVersion,
rpcHidReady, rpcHidReady,
rpcHidStatus, rpcHidStatus,

View File

@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { import {
hidErrorRollOver, hidErrorRollOver,
@ -9,7 +9,7 @@ import {
} from "@/hooks/stores"; } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidRpc } from "@/hooks/useHidRpc"; import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc"; import { KeyboardLedStateMessage, KeyboardMacro, KeysDownStateMessage } from "@/hooks/hidRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
@ -32,6 +32,7 @@ export default function useKeyboard() {
const { const {
reportKeyboardEvent: sendKeyboardEventHidRpc, reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc, reportKeypressEvent: sendKeypressEventHidRpc,
reportKeyboardMacroEvent: sendKeyboardMacroEventHidRpc,
rpcHidReady, rpcHidReady,
} = useHidRpc(message => { } = useHidRpc(message => {
switch (message.constructor) { switch (message.constructor) {
@ -77,16 +78,19 @@ export default function useKeyboard() {
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc], [rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
); );
const MACRO_RESET_KEYBOARD_STATE = useMemo(() => ({
keys: new Array(hidKeyBufferSize).fill(0),
modifier: 0,
delay: 0,
}), []);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. // resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state // This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean. // is clean.
const resetKeyboardState = useCallback(async () => { const resetKeyboardState = useCallback(async () => {
// Reset the keys buffer to zeros and the modifier state to zero // Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize; sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE);
keysDownState.keys.fill(0); }, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps. // executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay. // Each step can have multiple keys, multiple modifiers and a delay.
@ -97,7 +101,7 @@ export default function useKeyboard() {
const executeMacro = async ( const executeMacro = async (
steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[], steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
) => { ) => {
const macro: KeysDownState[] = []; const macro: KeyboardMacro[] = [];
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
@ -107,19 +111,12 @@ export default function useKeyboard() {
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) { if (keyValues.length > 0 || modifierMask > 0) {
macro.push({ keys: keyValues, modifier: modifierMask }); macro.push({ keys: keyValues, modifier: modifierMask, delay: 50 });
keysDownState.keys.length = hidKeyBufferSize; macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: 200 });
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
macro.push(keysDownState);
} }
} }
// KeyboardReportMessage
send("keyboardReportMulti", { macro }, (resp: JsonRpcResponse) => { sendKeyboardMacroEventHidRpc(macro);
if ("error" in resp) {
console.error(`Failed to send keyboard report ${macro}`, resp.error);
}
});
}; };
const cancelExecuteMacro = useCallback(async () => { const cancelExecuteMacro = useCallback(async () => {

View File

@ -116,6 +116,7 @@ if (isOnDevice) {
path: "/", path: "/",
errorElement: <ErrorBoundary />, errorElement: <ErrorBoundary />,
element: <DeviceRoute />, element: <DeviceRoute />,
HydrateFallback: () => <div className="p-4">Loading...</div>,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [ children: [
{ {

View File

@ -355,7 +355,7 @@ function UrlView({
const popularImages = [ const popularImages = [
{ {
name: "Ubuntu 24.04 LTS", name: "Ubuntu 24.04 LTS",
url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso", url: "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-desktop-amd64.iso",
icon: UbuntuIcon, icon: UbuntuIcon,
}, },
{ {
@ -369,8 +369,8 @@ function UrlView({
icon: DebianIcon, icon: DebianIcon,
}, },
{ {
name: "Fedora 41", name: "Fedora 42",
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", url: "https://download.fedoraproject.org/pub/fedora/linux/releases/42/Workstation/x86_64/iso/Fedora-Workstation-Live-42-1.1.x86_64.iso",
icon: FedoraIcon, icon: FedoraIcon,
}, },
{ {
@ -385,7 +385,7 @@ function UrlView({
}, },
{ {
name: "Arch Linux", name: "Arch Linux",
url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", url: "https://archlinux.doridian.net/iso/latest/archlinux-x86_64.iso",
icon: ArchIcon, icon: ArchIcon,
}, },
{ {

View File

@ -90,6 +90,7 @@ export default function SettingsMouseRoute() {
send("getJigglerState", {}, (resp: JsonRpcResponse) => { send("getJigglerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const isEnabled = resp.result as boolean; 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 the jiggler is disabled, set the selected option to "disabled" and nothing else
if (!isEnabled) return setSelectedJigglerOption("disabled"); if (!isEnabled) return setSelectedJigglerOption("disabled");

View File

@ -1,15 +1,16 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea"; import { TextAreaWithLabel } from "@/components/TextArea";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import notifications from "../notifications"; import Fieldset from "@components/Fieldset";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import notifications from "@/notifications";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
const defaultEdid = const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [ const edids = [
@ -50,21 +51,27 @@ export default function SettingsVideoRoute() {
const [streamQuality, setStreamQuality] = useState("1"); const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null); const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
const [edidLoading, setEdidLoading] = useState(false);
// Video enhancement settings from store // Video enhancement settings from store
const { const {
videoSaturation, setVideoSaturation, videoSaturation,
videoBrightness, setVideoBrightness, setVideoSaturation,
videoContrast, setVideoContrast videoBrightness,
setVideoBrightness,
videoContrast,
setVideoContrast,
} = useSettingsStore(); } = useSettingsStore();
useEffect(() => { useEffect(() => {
setEdidLoading(true);
send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => { send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
setStreamQuality(String(resp.result)); setStreamQuality(String(resp.result));
}); });
send("getEDID", {}, (resp: JsonRpcResponse) => { send("getEDID", {}, (resp: JsonRpcResponse) => {
setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return; return;
@ -89,28 +96,36 @@ export default function SettingsVideoRoute() {
}, [send]); }, [send]);
const handleStreamQualityChange = (factor: string) => { const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, (resp: JsonRpcResponse) => { send(
if ("error" in resp) { "setStreamQualityFactor",
notifications.error( { factor: Number(factor) },
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`, (resp: JsonRpcResponse) => {
); if ("error" in resp) {
return; notifications.error(
} `Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`); notifications.success(
setStreamQuality(factor); `Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`,
}); );
setStreamQuality(factor);
},
);
}; };
const handleEDIDChange = (newEdid: string) => { const handleEDIDChange = (newEdid: string) => {
setEdidLoading(true);
send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => { send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => {
setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`); notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return; return;
} }
notifications.success( notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`, `EDID set successfully to ${edids.find(x => x.value === newEdid)?.label ?? "the custom EDID"}`,
); );
// Update the EDID value in the UI // Update the EDID value in the UI
setEdid(newEdid); setEdid(newEdid);
@ -158,7 +173,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoSaturation} value={videoSaturation}
onChange={e => setVideoSaturation(parseFloat(e.target.value))} onChange={e => setVideoSaturation(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -173,7 +188,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoBrightness} value={videoBrightness}
onChange={e => setVideoBrightness(parseFloat(e.target.value))} onChange={e => setVideoBrightness(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -188,7 +203,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoContrast} value={videoContrast}
onChange={e => setVideoContrast(parseFloat(e.target.value))} onChange={e => setVideoContrast(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -205,60 +220,64 @@ export default function SettingsVideoRoute() {
/> />
</div> </div>
</div> </div>
<Fieldset disabled={edidLoading} className="space-y-2">
<SettingsItem <SettingsItem
title="EDID" title="EDID"
description="Adjust the EDID settings for the display" description="Adjust the EDID settings for the display"
> loading={edidLoading}
<SelectMenuBasic >
size="SM" <SelectMenuBasic
label="" size="SM"
fullWidth label=""
value={customEdidValue ? "custom" : edid || "asd"} fullWidth
onChange={e => { value={customEdidValue ? "custom" : edid || "asd"}
if (e.target.value === "custom") { onChange={e => {
setEdid("custom"); if (e.target.value === "custom") {
setCustomEdidValue(""); setEdid("custom");
} else { setCustomEdidValue("");
setCustomEdidValue(null); } else {
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null); setCustomEdidValue(null);
handleEDIDChange(defaultEdid); handleEDIDChange(e.target.value as string);
}} }
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/> />
</div> <TextAreaWithLabel
</> label="EDID File"
)} placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
loading={edidLoading}
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
loading={edidLoading}
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</Fieldset>
</div> </div>
</div> </div>
</div> </div>

View File

@ -94,6 +94,17 @@ export const formatters = {
}, },
}; };
export function someIterable<T>(
iterable: Iterable<T>,
predicate: (item: T) => boolean,
): boolean {
for (const item of iterable) {
if (predicate(item)) return true;
}
return false;
}
export const VIDEO = new Blob( export const VIDEO = new Blob(
[ [
new Uint8Array([ new Uint8Array([