Compare commits

..

No commits in common. "8a189ba1b9afacf6e08006b79e667692a4ff363c" and "fcd07b2b59c32f5c696b498a39a17ffa468d4ff3" have entirely different histories.

161 changed files with 4247 additions and 5616 deletions

View File

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

View File

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

View File

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

View File

@ -1,180 +0,0 @@
package kvm
import (
"context"
"net/http"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/jetkvm/kvm/internal/audio"
"github.com/rs/zerolog"
)
var audioControlService *audio.AudioControlService
func initAudioControlService() {
if audioControlService == nil {
sessionProvider := &SessionProviderImpl{}
audioControlService = audio.NewAudioControlService(sessionProvider, logger)
}
}
// handleAudioMute handles POST /audio/mute requests
func handleAudioMute(c *gin.Context) {
type muteReq struct {
Muted bool `json:"muted"`
}
var req muteReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
initAudioControlService()
err := audioControlService.MuteAudio(req.Muted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"status": "audio mute state updated",
"muted": req.Muted,
})
}
// handleMicrophoneStart handles POST /microphone/start requests
func handleMicrophoneStart(c *gin.Context) {
initAudioControlService()
err := audioControlService.StartMicrophone()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// handleMicrophoneMute handles POST /microphone/mute requests
func handleMicrophoneMute(c *gin.Context) {
var req struct {
Muted bool `json:"muted"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
initAudioControlService()
err := audioControlService.MuteMicrophone(req.Muted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// handleMicrophoneReset handles POST /microphone/reset requests
func handleMicrophoneReset(c *gin.Context) {
initAudioControlService()
err := audioControlService.ResetMicrophone()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// handleSubscribeAudioEvents handles WebSocket audio event subscription
func handleSubscribeAudioEvents(connectionID string, wsCon *websocket.Conn, runCtx context.Context, l *zerolog.Logger) {
initAudioControlService()
audioControlService.SubscribeToAudioEvents(connectionID, wsCon, runCtx, l)
}
// handleUnsubscribeAudioEvents handles WebSocket audio event unsubscription
func handleUnsubscribeAudioEvents(connectionID string, l *zerolog.Logger) {
initAudioControlService()
audioControlService.UnsubscribeFromAudioEvents(connectionID, l)
}
// handleAudioQuality handles GET requests for audio quality presets
func handleAudioQuality(c *gin.Context) {
initAudioControlService()
presets := audioControlService.GetAudioQualityPresets()
current := audioControlService.GetCurrentAudioQuality()
c.JSON(200, gin.H{
"presets": presets,
"current": current,
})
}
// handleSetAudioQuality handles POST requests to set audio quality
func handleSetAudioQuality(c *gin.Context) {
var req struct {
Quality int `json:"quality"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
initAudioControlService()
// Convert int to AudioQuality type
quality := audio.AudioQuality(req.Quality)
// Set the audio quality
audioControlService.SetAudioQuality(quality)
// Return the updated configuration
current := audioControlService.GetCurrentAudioQuality()
c.JSON(200, gin.H{
"success": true,
"config": current,
})
}
// handleMicrophoneQuality handles GET requests for microphone quality presets
func handleMicrophoneQuality(c *gin.Context) {
initAudioControlService()
presets := audioControlService.GetMicrophoneQualityPresets()
current := audioControlService.GetCurrentMicrophoneQuality()
c.JSON(200, gin.H{
"presets": presets,
"current": current,
})
}
// handleSetMicrophoneQuality handles POST requests to set microphone quality
func handleSetMicrophoneQuality(c *gin.Context) {
var req struct {
Quality int `json:"quality"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
initAudioControlService()
// Convert int to AudioQuality type
quality := audio.AudioQuality(req.Quality)
// Set the microphone quality
audioControlService.SetMicrophoneQuality(quality)
// Return the updated configuration
current := audioControlService.GetCurrentMicrophoneQuality()
c.JSON(http.StatusOK, gin.H{
"success": true,
"config": current,
})
}

View File

@ -1,24 +0,0 @@
package kvm
import "github.com/jetkvm/kvm/internal/audio"
// SessionProviderImpl implements the audio.SessionProvider interface
type SessionProviderImpl struct{}
// NewSessionProvider creates a new session provider
func NewSessionProvider() *SessionProviderImpl {
return &SessionProviderImpl{}
}
// IsSessionActive returns whether there's an active session
func (sp *SessionProviderImpl) IsSessionActive() bool {
return currentSession != nil
}
// GetAudioInputManager returns the current session's audio input manager
func (sp *SessionProviderImpl) GetAudioInputManager() *audio.AudioInputManager {
if currentSession == nil {
return nil
}
return currentSession.AudioInputManager
}

View File

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

View File

@ -39,7 +39,8 @@ const (
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
WebsocketPingInterval = 15 * time.Second
// Increased to 30 seconds for constrained environments to reduce overhead
WebsocketPingInterval = 30 * time.Second
)
var (
@ -76,23 +77,6 @@ var (
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration_seconds",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_sent_total",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_received_total",
@ -100,13 +84,6 @@ var (
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_requests_total",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration_seconds",
@ -447,7 +424,17 @@ func handleSessionRequest(
}
}
session, err := newSession(SessionConfig{
var session *Session
var err error
var sd string
// Check if we have an existing session
if currentSession != nil {
scopedLogger.Info().Msg("existing session detected, creating new session and notifying old session")
// Always create a new session when there's an existing one
// This ensures the "otherSessionConnected" prompt is shown
session, err = newSession(SessionConfig{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
@ -459,23 +446,48 @@ func handleSessionRequest(
return err
}
sd, err := session.ExchangeOffer(req.Sd)
sd, err = session.ExchangeOffer(req.Sd)
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
}
if currentSession != nil {
// Notify the old session about the takeover
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection
go func() {
time.Sleep(1 * time.Second)
_ = peerConn.Close()
}()
currentSession = session
scopedLogger.Info().Interface("session", session).Msg("new session created, old session notified")
} else {
// No existing session, create a new one
scopedLogger.Info().Msg("creating new session")
session, err = newSession(SessionConfig{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
Logger: scopedLogger,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
}
sd, err = session.ExchangeOffer(req.Sd)
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
}
currentSession = session
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
currentSession = session
}
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil
}

View File

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

View File

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

114
fuse.go Normal file
View File

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

39
go.mod
View File

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

80
go.sum
View File

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

162
hidrpc.go
View File

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

View File

@ -112,8 +112,7 @@ func handleKeyboardReportDirect(params map[string]interface{}) (interface{}, err
return nil, err
}
_, err = rpcKeyboardReport(modifier, keys)
return nil, err
return nil, rpcKeyboardReport(modifier, keys)
}
// Direct handler for absolute mouse reports

View File

@ -941,6 +941,8 @@ func cgoAudioReadEncode(buf []byte) (int, error) {
}
// Skip initialization check for now to avoid CGO compilation issues
// Note: The C code already has comprehensive state tracking with capture_initialized,
// capture_initializing, playback_initialized, and playback_initializing flags.
// Direct CGO call with minimal overhead - unsafe.Pointer(&slice[0]) is safe for validated non-empty buffers
n := C.jetkvm_audio_read_encode(unsafe.Pointer(&buf[0]))
@ -1082,6 +1084,72 @@ func ReturnBufferToPool(buf []byte) {
ReturnOptimalBuffer(buf)
}
// Note: AudioFrameBatch is now defined in batch_audio.go
// This is kept here for reference but commented out to avoid conflicts
/*
// AudioFrameBatch represents a batch of audio frames for processing
type AudioFrameBatch struct {
// Buffer for batch processing
buffer []byte
// Number of frames in the batch
frameCount int
// Size of each frame
frameSize int
// Current position in the buffer
position int
}
// NewAudioFrameBatch creates a new audio frame batch with the specified capacity
func NewAudioFrameBatch(maxFrames int) *AudioFrameBatch {
// Get cached config
cache := GetCachedConfig()
cache.Update()
// Calculate frame size based on cached config
frameSize := cache.GetMinReadEncodeBuffer()
// Create batch with buffer sized for maxFrames
return &AudioFrameBatch{
buffer: GetBufferFromPool(maxFrames * frameSize),
frameCount: 0,
frameSize: frameSize,
position: 0,
}
}
// AddFrame adds a frame to the batch
// Returns true if the batch is full after adding this frame
func (b *AudioFrameBatch) AddFrame(frame []byte) bool {
// Calculate position in buffer for this frame
pos := b.position
// Copy frame data to batch buffer
copy(b.buffer[pos:pos+len(frame)], frame)
// Update position and frame count
b.position += len(frame)
b.frameCount++
// Check if batch is full (buffer capacity reached)
return b.position >= len(b.buffer)
}
// Reset resets the batch for reuse
func (b *AudioFrameBatch) Reset() {
b.frameCount = 0
b.position = 0
}
// Release returns the batch buffer to the pool
func (b *AudioFrameBatch) Release() {
ReturnBufferToPool(b.buffer)
b.buffer = nil
b.frameCount = 0
b.frameSize = 0
b.position = 0
}
*/
// ReadEncodeWithPooledBuffer reads audio data and encodes it using a buffer from the pool
func ReadEncodeWithPooledBuffer() ([]byte, int, error) {
cache := GetCachedConfig()

View File

@ -143,23 +143,23 @@ type AudioConfigConstants struct {
CGOMaxPacketSize int
// Input IPC Constants - Configuration for audio input inter-process communication
// Used in: ipc_input.go for microphone audio capture and processing
// Used in: input_ipc.go for microphone audio capture and processing
// Impact: Controls audio input quality and processing efficiency
// InputIPCSampleRate defines sample rate for input IPC audio processing (Hz).
// Used in: ipc_input.go for microphone capture configuration
// Used in: input_ipc.go for microphone capture configuration
// Impact: Must match microphone capabilities and encoding requirements.
// Default 48000Hz provides professional quality microphone input.
InputIPCSampleRate int
// InputIPCChannels defines channel count for input IPC audio processing.
// Used in: ipc_input.go for microphone channel configuration
// Used in: input_ipc.go for microphone channel configuration
// Impact: Stereo (2) captures spatial audio, mono (1) reduces processing.
// Default 2 (stereo) supports full microphone array capabilities.
InputIPCChannels int
// InputIPCFrameSize defines frame size for input IPC processing (samples).
// Used in: ipc_input.go for microphone frame processing
// Used in: input_ipc.go for microphone frame processing
// Impact: Larger frames reduce overhead but increase input latency.
// Default 960 samples (20ms at 48kHz) balances latency and efficiency.
InputIPCFrameSize int
@ -209,36 +209,36 @@ type AudioConfigConstants struct {
SocketMinBuffer int
// Scheduling Policy Constants - Linux process scheduling policies for audio threads
// Used in: monitor_process.go for configuring thread scheduling behavior
// Used in: process_monitor.go for configuring thread scheduling behavior
// Impact: Controls how audio threads are scheduled by the Linux kernel
// Removed unused scheduling policy constants and RT priority values
// The priority scheduler is not implemented - functions are called but don't exist
// Process Management - Configuration for audio process lifecycle management
// Used in: output_supervisor.go for managing audio process restarts and recovery
// Used in: supervisor.go for managing audio process restarts and recovery
// Impact: Controls system resilience and recovery from audio process failures
// MaxRestartAttempts defines maximum number of restart attempts for failed processes.
// Used in: output_supervisor.go for limiting restart attempts to prevent infinite loops
// Used in: supervisor.go for limiting restart attempts to prevent infinite loops
// Impact: Higher values increase resilience but may mask persistent problems.
// Default 5 attempts allows recovery from transient issues while detecting persistent failures.
MaxRestartAttempts int
// RestartWindow defines time window for counting restart attempts.
// Used in: output_supervisor.go for restart attempt rate limiting
// Used in: supervisor.go for restart attempt rate limiting
// Impact: Longer windows allow more restart attempts but slower failure detection.
// Default 5 minutes provides reasonable window for transient issue recovery.
RestartWindow time.Duration
// RestartDelay defines initial delay before restarting failed processes.
// Used in: output_supervisor.go for implementing restart backoff strategy
// Used in: supervisor.go for implementing restart backoff strategy
// Impact: Longer delays reduce restart frequency but increase recovery time.
// Default 2 seconds allows brief recovery time without excessive delay.
RestartDelay time.Duration
// MaxRestartDelay defines maximum delay between restart attempts.
// Used in: output_supervisor.go for capping exponential backoff delays
// Used in: supervisor.go for capping exponential backoff delays
// Impact: Prevents excessively long delays while maintaining backoff benefits.
// Default 30 seconds caps restart delays at reasonable maximum.
MaxRestartDelay time.Duration
@ -248,13 +248,13 @@ type AudioConfigConstants struct {
// Impact: Controls memory usage, allocation efficiency, and processing performance
// PreallocSize defines size of preallocated memory pools (bytes).
// Used in: util_buffer_pool.go for initial memory pool allocation
// Used in: buffer_pool.go for initial memory pool allocation
// Impact: Larger pools reduce allocation overhead but increase memory usage.
// Default 1MB (1024*1024) provides good balance for typical audio workloads.
PreallocSize int
// MaxPoolSize defines maximum number of objects in memory pools.
// Used in: util_buffer_pool.go for limiting pool growth
// Used in: buffer_pool.go for limiting pool growth
// Impact: Larger pools reduce allocation frequency but increase memory usage.
// Default 100 objects provides good balance between performance and memory.
MaxPoolSize int
@ -290,13 +290,13 @@ type AudioConfigConstants struct {
ChannelBufferSize int
// AudioFramePoolSize defines size of audio frame object pools.
// Used in: util_buffer_pool.go for audio frame allocation
// Used in: buffer_pool.go for audio frame allocation
// Impact: Larger pools reduce allocation overhead for frame processing.
// Default 1500 frames handles typical audio frame throughput efficiently.
AudioFramePoolSize int
// PageSize defines memory page size for alignment and allocation (bytes).
// Used in: util_buffer_pool.go for memory-aligned allocations
// Used in: buffer_pool.go for memory-aligned allocations
// Impact: Must match system page size for optimal memory performance.
// Default 4096 bytes matches typical Linux page size.
PageSize int
@ -332,61 +332,61 @@ type AudioConfigConstants struct {
MinBatchSizeForThreadPinning int
// GoroutineMonitorInterval defines the interval for monitoring goroutine counts.
// Used in: monitor_goroutine.go for periodic goroutine count checks.
// Used in: goroutine_monitor.go for periodic goroutine count checks.
// Impact: Shorter intervals provide more frequent monitoring but increase overhead.
// Default 30 seconds provides reasonable monitoring frequency with minimal overhead.
GoroutineMonitorInterval time.Duration
// IPC Configuration - Inter-Process Communication settings for audio components
// Used in: ipc_output.go for configuring audio process communication
// Used in: ipc.go for configuring audio process communication
// Impact: Controls IPC reliability, performance, and protocol compliance
// MagicNumber defines magic number for IPC message validation.
// Used in: ipc_output.go for message header validation and protocol compliance
// Used in: ipc.go for message header validation and protocol compliance
// Impact: Must match expected value to prevent protocol errors.
// Default 0xDEADBEEF provides distinctive pattern for message validation.
MagicNumber uint32
// MaxFrameSize defines maximum frame size for IPC messages (bytes).
// Used in: ipc_output.go for message size validation and buffer allocation
// Used in: ipc.go for message size validation and buffer allocation
// Impact: Must accommodate largest expected audio frame to prevent truncation.
// Default 4096 bytes handles typical audio frames with safety margin.
MaxFrameSize int
// WriteTimeout defines timeout for IPC write operations.
// Used in: ipc_output.go for preventing blocking on slow IPC operations
// Used in: ipc.go for preventing blocking on slow IPC operations
// Impact: Shorter timeouts improve responsiveness but may cause message drops.
// Default 5 seconds allows for system load while preventing indefinite blocking.
WriteTimeout time.Duration
// MaxDroppedFrames defines maximum consecutive dropped frames before error.
// Used in: ipc_output.go for IPC quality monitoring
// Used in: ipc.go for IPC quality monitoring
// Impact: Higher values tolerate more IPC issues but may mask problems.
// Default 10 frames allows brief interruptions while detecting serious issues.
// HeaderSize defines size of IPC message headers (bytes).
// Used in: ipc_output.go for message parsing and buffer allocation
// Used in: ipc.go for message parsing and buffer allocation
// Impact: Must match actual header size to prevent parsing errors.
// Default 8 bytes matches current IPC message header format.
HeaderSize int
// Monitoring and Metrics - Configuration for audio performance monitoring
// Used in: core_metrics.go, monitor_latency.go for performance tracking
// Used in: metrics.go, latency_monitor.go for performance tracking
// Impact: Controls monitoring accuracy, overhead, and data retention
// MetricsUpdateInterval defines frequency of metrics collection and reporting.
// Used in: core_metrics.go for periodic metrics updates
// Used in: metrics.go for periodic metrics updates
// Impact: Shorter intervals provide more accurate monitoring but increase overhead.
// Default 1000ms (1 second) provides good balance between accuracy and performance.
MetricsUpdateInterval time.Duration
// EMAAlpha defines smoothing factor for Exponential Moving Average calculations.
// Used in: core_metrics.go for smoothing performance metrics
// Used in: metrics.go for smoothing performance metrics
// Impact: Higher values respond faster to changes but are more sensitive to noise.
// Default 0.1 provides good smoothing while maintaining responsiveness.
// WarmupSamples defines number of samples to collect before reporting metrics.
// Used in: core_metrics.go for avoiding inaccurate initial measurements
// Used in: metrics.go for avoiding inaccurate initial measurements
// Impact: More samples improve initial accuracy but delay metric availability.
// Default 10 samples provides good initial accuracy without excessive delay.
WarmupSamples int
@ -397,23 +397,23 @@ type AudioConfigConstants struct {
// Default 5 seconds prevents log flooding while maintaining visibility.
// MetricsChannelBuffer defines buffer size for metrics data channels.
// Used in: core_metrics.go for metrics data collection pipelines
// Used in: metrics.go for metrics data collection pipelines
// Impact: Larger buffers reduce blocking but increase memory usage and latency.
// Default 100 metrics provides good balance for metrics collection.
MetricsChannelBuffer int
// LatencyHistorySize defines number of latency measurements to retain.
// Used in: monitor_latency.go for latency trend analysis
// Used in: latency_monitor.go for latency trend analysis
// Impact: More history improves trend analysis but increases memory usage.
// Default 100 measurements provides good history for analysis.
LatencyHistorySize int
// Process Monitoring Constants - System resource monitoring configuration
// Used in: monitor_process.go for monitoring CPU, memory, and system resources
// Used in: process_monitor.go for monitoring CPU, memory, and system resources
// Impact: Controls resource monitoring accuracy and system compatibility
// MaxCPUPercent defines maximum valid CPU percentage value.
// Used in: monitor_process.go for CPU usage validation
// Used in: process_monitor.go for CPU usage validation
// Impact: Values above this are considered invalid and filtered out.
// Default 100.0 represents 100% CPU usage as maximum valid value.
MaxCPUPercent float64
@ -425,37 +425,37 @@ type AudioConfigConstants struct {
MinCPUPercent float64
// DefaultClockTicks defines default system clock ticks per second.
// Used in: monitor_process.go for CPU time calculations on embedded systems
// Used in: process_monitor.go for CPU time calculations on embedded systems
// Impact: Must match system configuration for accurate CPU measurements.
// Default 250.0 matches typical embedded ARM system configuration.
DefaultClockTicks float64
// DefaultMemoryGB defines default system memory size in gigabytes.
// Used in: monitor_process.go for memory percentage calculations
// Used in: process_monitor.go for memory percentage calculations
// Impact: Should match actual system memory for accurate percentage calculations.
// Default 8 GB represents typical JetKVM system memory configuration.
DefaultMemoryGB int
// MaxWarmupSamples defines maximum number of warmup samples for monitoring.
// Used in: monitor_process.go for initial measurement stabilization
// Used in: process_monitor.go for initial measurement stabilization
// Impact: More samples improve initial accuracy but delay monitoring start.
// Default 3 samples provides quick stabilization without excessive delay.
MaxWarmupSamples int
// WarmupCPUSamples defines number of CPU samples for warmup period.
// Used in: monitor_process.go for CPU measurement stabilization
// Used in: process_monitor.go for CPU measurement stabilization
// Impact: More samples improve CPU measurement accuracy during startup.
// Default 2 samples provides basic CPU measurement stabilization.
WarmupCPUSamples int
// LogThrottleIntervalSec defines log throttle interval in seconds.
// Used in: monitor_process.go for controlling monitoring log frequency
// Used in: process_monitor.go for controlling monitoring log frequency
// Impact: Longer intervals reduce log volume but may miss monitoring events.
// Default 10 seconds provides reasonable monitoring log frequency.
LogThrottleIntervalSec int
// MinValidClockTicks defines minimum valid system clock ticks value.
// Used in: monitor_process.go for system clock validation
// Used in: process_monitor.go for system clock validation
// Impact: Values below this indicate system configuration issues.
// Default 50 ticks represents minimum reasonable system clock configuration.
MinValidClockTicks int
@ -467,63 +467,63 @@ type AudioConfigConstants struct {
MaxValidClockTicks int
// Performance Tuning - Thresholds for adaptive audio quality and resource management
// Used in: monitor_adaptive_optimizer.go, quality_manager.go for performance optimization
// Used in: adaptive_optimizer.go, quality_manager.go for performance optimization
// Impact: Controls when audio quality adjustments are triggered based on system load
// CPUThresholdLow defines CPU usage threshold for low system load.
// Used in: monitor_adaptive_optimizer.go for triggering quality improvements
// Used in: adaptive_optimizer.go for triggering quality improvements
// Impact: Below this threshold, audio quality can be increased safely.
// Default 20% allows quality improvements when system has spare capacity.
// CPUThresholdMedium defines CPU usage threshold for medium system load.
// Used in: monitor_adaptive_optimizer.go for maintaining current quality
// Used in: adaptive_optimizer.go for maintaining current quality
// Impact: Between low and medium thresholds, quality remains stable.
// Default 60% represents balanced system load where quality should be maintained.
// CPUThresholdHigh defines CPU usage threshold for high system load.
// Used in: monitor_adaptive_optimizer.go for triggering quality reductions
// Used in: adaptive_optimizer.go for triggering quality reductions
// Impact: Above this threshold, audio quality is reduced to preserve performance.
// Default 75% prevents system overload by reducing audio processing demands.
// MemoryThresholdLow defines memory usage threshold for low memory pressure.
// Used in: monitor_adaptive_optimizer.go for memory-based quality decisions
// Used in: adaptive_optimizer.go for memory-based quality decisions
// Impact: Below this threshold, memory-intensive audio features can be enabled.
// Default 30% allows enhanced features when memory is abundant.
// MemoryThresholdMed defines memory usage threshold for medium memory pressure.
// Used in: monitor_adaptive_optimizer.go for balanced memory management
// Used in: adaptive_optimizer.go for balanced memory management
// Impact: Between low and medium thresholds, memory usage is monitored closely.
// Default 60% represents moderate memory pressure requiring careful management.
// MemoryThresholdHigh defines memory usage threshold for high memory pressure.
// Used in: monitor_adaptive_optimizer.go for aggressive memory conservation
// Used in: adaptive_optimizer.go for aggressive memory conservation
// Impact: Above this threshold, memory usage is minimized by reducing quality.
// Default 80% triggers aggressive memory conservation to prevent system issues.
// LatencyThresholdLow defines acceptable latency for high-quality audio.
// Used in: monitor_adaptive_optimizer.go for latency-based quality decisions
// Used in: adaptive_optimizer.go for latency-based quality decisions
// Impact: Below this threshold, audio quality can be maximized.
// Default 20ms represents excellent latency allowing maximum quality.
// LatencyThresholdHigh defines maximum acceptable latency before quality reduction.
// Used in: monitor_adaptive_optimizer.go for preventing excessive audio delay
// Used in: adaptive_optimizer.go for preventing excessive audio delay
// Impact: Above this threshold, quality is reduced to improve latency.
// Default 50ms represents maximum acceptable latency for real-time audio.
// CPUFactor defines weighting factor for CPU usage in performance calculations.
// Used in: monitor_adaptive_optimizer.go for balancing CPU impact in optimization decisions
// Used in: adaptive_optimizer.go for balancing CPU impact in optimization decisions
// Impact: Higher values make CPU usage more influential in performance tuning.
// Default 0.5 provides balanced CPU consideration in optimization algorithms.
CPUFactor float64
// MemoryFactor defines weighting factor for memory usage in performance calculations.
// Used in: monitor_adaptive_optimizer.go for balancing memory impact in optimization decisions
// Used in: adaptive_optimizer.go for balancing memory impact in optimization decisions
// Impact: Higher values make memory usage more influential in performance tuning.
// Default 0.3 provides moderate memory consideration in optimization algorithms.
MemoryFactor float64
// LatencyFactor defines weighting factor for latency in performance calculations.
// Used in: monitor_adaptive_optimizer.go for balancing latency impact in optimization decisions
// Used in: adaptive_optimizer.go for balancing latency impact in optimization decisions
// Impact: Higher values make latency more influential in performance tuning.
// Default 0.2 provides latency consideration while prioritizing CPU and memory.
LatencyFactor float64
@ -539,7 +539,7 @@ type AudioConfigConstants struct {
// Default 2048 bytes accommodates larger output buffers typical in audio processing.
// TargetLevel defines target performance level for optimization algorithms.
// Used in: monitor_adaptive_optimizer.go for setting optimization goals
// Used in: adaptive_optimizer.go for setting optimization goals
// Impact: Higher values aim for better performance but may increase resource usage.
// Default 0.8 (80%) provides good performance while maintaining system stability.
@ -666,18 +666,18 @@ type AudioConfigConstants struct {
DefaultTickerInterval time.Duration // 100ms
// BufferUpdateInterval defines frequency of buffer status updates.
// Used in: util_buffer_pool.go and adaptive_buffer.go for buffer management
// Used in: buffer_pool.go and adaptive_buffer.go for buffer management
// Impact: More frequent updates improve responsiveness but increase overhead.
// Default 500ms provides adequate buffer monitoring without excessive overhead.
BufferUpdateInterval time.Duration // 500ms
// StatsUpdateInterval defines frequency of statistics collection and reporting.
// Used in: core_metrics.go for performance statistics updates
// Used in: metrics.go for performance statistics updates
// Impact: More frequent updates provide better monitoring but increase overhead.
// Default 5s provides comprehensive statistics without performance impact.
// SupervisorTimeout defines timeout for supervisor process operations.
// Used in: output_supervisor.go for process monitoring and control
// Used in: supervisor.go for process monitoring and control
// Impact: Shorter timeouts improve responsiveness but may cause false timeouts.
// Default 10s provides adequate time for supervisor operations.
SupervisorTimeout time.Duration // 10s
@ -689,7 +689,7 @@ type AudioConfigConstants struct {
InputSupervisorTimeout time.Duration // 5s
// OutputSupervisorTimeout defines timeout for output supervisor operations.
// Used in: output_supervisor.go for output process monitoring
// Used in: supervisor.go for output process monitoring
// Impact: Shorter timeouts improve output responsiveness but may cause false timeouts.
// Default 5s provides responsive output monitoring.
OutputSupervisorTimeout time.Duration // 5s
@ -711,13 +711,13 @@ type AudioConfigConstants struct {
BatchProcessingDelay time.Duration // 10ms
// AdaptiveOptimizerStability defines stability period for adaptive optimization.
// Used in: monitor_adaptive_optimizer.go for optimization stability control
// Used in: adaptive_optimizer.go for optimization stability control
// Impact: Longer periods provide more stable optimization but slower adaptation.
// Default 10s provides good balance between stability and adaptability.
AdaptiveOptimizerStability time.Duration // 10s
// LatencyMonitorTarget defines target latency for latency monitoring system.
// Used in: monitor_latency.go for latency optimization goals and threshold monitoring
// Used in: latency_monitor.go for latency optimization goals and threshold monitoring
// Impact: Lower targets improve audio responsiveness but may increase system load.
// Default 50ms provides excellent real-time audio performance target.
LatencyMonitorTarget time.Duration // 50ms
@ -757,51 +757,51 @@ type AudioConfigConstants struct {
AdaptiveBufferTargetLatency time.Duration // 20ms target latency
// Adaptive Optimizer Configuration - Settings for performance optimization
// Used in: monitor_adaptive_optimizer.go for system performance optimization
// Used in: adaptive_optimizer.go for system performance optimization
// Impact: Controls optimization behavior and stability
// CooldownPeriod defines minimum time between optimization adjustments.
// Used in: monitor_adaptive_optimizer.go for preventing optimization oscillation
// Used in: adaptive_optimizer.go for preventing optimization oscillation
// Impact: Longer periods provide more stable optimization but slower adaptation.
// Default 30s prevents rapid optimization changes that could destabilize system.
CooldownPeriod time.Duration // 30s cooldown period
// RollbackThreshold defines latency threshold for optimization rollback.
// Used in: monitor_adaptive_optimizer.go for detecting failed optimizations
// Used in: adaptive_optimizer.go for detecting failed optimizations
// Impact: Lower thresholds trigger faster rollback but may be too sensitive.
// Default 300ms provides clear indication of optimization failure.
RollbackThreshold time.Duration // 300ms rollback threshold
// AdaptiveOptimizerLatencyTarget defines target latency for adaptive optimizer.
// Used in: monitor_adaptive_optimizer.go for optimization target setting
// Used in: adaptive_optimizer.go for optimization target setting
// Impact: Lower targets improve responsiveness but may increase system load.
// Default 50ms provides good balance between performance and stability.
AdaptiveOptimizerLatencyTarget time.Duration // 50ms latency target
// Latency Monitor Configuration - Settings for latency monitoring and analysis
// Used in: monitor_latency.go for latency tracking and alerting
// Used in: latency_monitor.go for latency tracking and alerting
// Impact: Controls latency monitoring sensitivity and thresholds
// MaxLatencyThreshold defines maximum acceptable latency before alerts.
// Used in: monitor_latency.go for latency violation detection
// Used in: latency_monitor.go for latency violation detection
// Impact: Lower values provide stricter latency enforcement.
// Default 200ms defines clear boundary for unacceptable latency.
MaxLatencyThreshold time.Duration // 200ms max latency
// JitterThreshold defines maximum acceptable latency variation.
// Used in: monitor_latency.go for jitter detection and monitoring
// Used in: latency_monitor.go for jitter detection and monitoring
// Impact: Lower values detect smaller latency variations.
// Default 20ms provides good jitter detection for audio quality.
JitterThreshold time.Duration // 20ms jitter threshold
// LatencyOptimizationInterval defines interval for latency optimization cycles.
// Used in: monitor_latency.go for optimization timing control
// Used in: latency_monitor.go for optimization timing control
// Impact: Controls frequency of latency optimization adjustments.
// Default 5s provides balanced optimization without excessive overhead.
LatencyOptimizationInterval time.Duration // 5s optimization interval
// LatencyAdaptiveThreshold defines threshold for adaptive latency adjustments.
// Used in: monitor_latency.go for adaptive optimization decisions
// Used in: latency_monitor.go for adaptive optimization decisions
// Impact: Controls sensitivity of adaptive latency optimization.
// Default 0.8 (80%) provides good balance between stability and adaptation.
LatencyAdaptiveThreshold float64 // 0.8 adaptive threshold
@ -836,26 +836,26 @@ type AudioConfigConstants struct {
// Default 19 provides maximum priority reduction capability.
// Buffer Pool Configuration - Settings for memory pool preallocation
// Used in: util_buffer_pool.go for memory pool management
// Used in: buffer_pool.go for memory pool management
// Impact: Controls memory preallocation strategy and efficiency
// PreallocPercentage defines percentage of buffers to preallocate.
// Used in: util_buffer_pool.go for initial memory pool sizing
// Used in: buffer_pool.go for initial memory pool sizing
// Impact: Higher values reduce allocation overhead but increase memory usage.
// Default 20% provides good balance between performance and memory efficiency.
PreallocPercentage int // 20% preallocation percentage
// InputPreallocPercentage defines percentage of input buffers to preallocate.
// Used in: util_buffer_pool.go for input-specific memory pool sizing
// Used in: buffer_pool.go for input-specific memory pool sizing
// Impact: Higher values improve input performance but increase memory usage.
// Default 30% provides enhanced input performance with reasonable memory usage.
// Exponential Moving Average Configuration - Settings for statistical smoothing
// Used in: core_metrics.go and various monitoring components
// Used in: metrics.go and various monitoring components
// Impact: Controls smoothing behavior for performance metrics
// HistoricalWeight defines weight given to historical data in EMA calculations.
// Used in: core_metrics.go for exponential moving average calculations
// Used in: metrics.go for exponential moving average calculations
// Impact: Higher values provide more stable metrics but slower response to changes.
// Default 70% provides good stability while maintaining responsiveness.
@ -880,17 +880,17 @@ type AudioConfigConstants struct {
BackoffStart time.Duration // 50ms initial backoff
// Protocol Magic Numbers - Unique identifiers for IPC message validation
// Used in: ipc_input.go for message protocol validation
// Used in: ipc.go, input_ipc.go for message protocol validation
// Impact: Must match expected values to ensure proper message routing
// InputMagicNumber defines magic number for input IPC messages.
// Used in: ipc_input.go for input message validation
// Used in: input_ipc.go for input message validation
// Impact: Must match expected value to prevent input protocol errors.
// Default 0x4A4B4D49 "JKMI" (JetKVM Microphone Input) provides distinctive input identifier.
InputMagicNumber uint32
// OutputMagicNumber defines magic number for output IPC messages.
// Used in: ipc_output.go for output message validation
// Used in: ipc.go for output message validation
// Impact: Must match expected value to prevent output protocol errors.
// Default 0x4A4B4F55 "JKOU" (JetKVM Output) provides distinctive output identifier.
OutputMagicNumber uint32
@ -900,13 +900,13 @@ type AudioConfigConstants struct {
// Impact: Controls precision and behavior of audio processing algorithms
// PercentageMultiplier defines multiplier for percentage calculations.
// Used in: core_metrics.go, monitor_process.go for percentage conversions
// Used in: metrics.go, process_monitor.go for percentage conversions
// Impact: Must be 100.0 for accurate percentage calculations.
// Default 100.0 provides standard percentage calculation base.
PercentageMultiplier float64
// AveragingWeight defines weight for weighted averaging calculations.
// Used in: core_metrics.go for exponential moving averages
// Used in: metrics.go for exponential moving averages
// Impact: Higher values emphasize historical data more heavily.
// Default 0.7 provides good balance between stability and responsiveness.
AveragingWeight float64
@ -924,37 +924,37 @@ type AudioConfigConstants struct {
SmoothingFactor float64
// CPUMemoryWeight defines weight for CPU factor in combined calculations.
// Used in: monitor_adaptive_optimizer.go for balancing CPU vs memory considerations
// Used in: adaptive_optimizer.go for balancing CPU vs memory considerations
// Impact: Higher values prioritize CPU optimization over memory optimization.
// Default 0.5 provides equal weighting between CPU and memory factors.
CPUMemoryWeight float64
// MemoryWeight defines weight for memory factor in combined calculations.
// Used in: monitor_adaptive_optimizer.go for memory impact weighting
// Used in: adaptive_optimizer.go for memory impact weighting
// Impact: Higher values make memory usage more influential in decisions.
// Default 0.3 provides moderate memory consideration in optimization.
MemoryWeight float64
// LatencyWeight defines weight for latency factor in combined calculations.
// Used in: monitor_adaptive_optimizer.go for latency impact weighting
// Used in: adaptive_optimizer.go for latency impact weighting
// Impact: Higher values prioritize latency optimization over resource usage.
// Default 0.2 provides latency consideration while prioritizing resources.
LatencyWeight float64
// PoolGrowthMultiplier defines multiplier for pool size growth.
// Used in: util_buffer_pool.go for pool expansion calculations
// Used in: buffer_pool.go for pool expansion calculations
// Impact: Higher values cause more aggressive pool growth.
// Default 2 provides standard doubling growth pattern.
PoolGrowthMultiplier int
// LatencyScalingFactor defines scaling factor for latency ratio calculations.
// Used in: monitor_latency.go for latency scaling operations
// Used in: latency_monitor.go for latency scaling operations
// Impact: Higher values amplify latency differences in calculations.
// Default 2.0 provides moderate latency scaling for monitoring.
LatencyScalingFactor float64
// OptimizerAggressiveness defines aggressiveness level for optimization algorithms.
// Used in: monitor_adaptive_optimizer.go for optimization behavior control
// Used in: adaptive_optimizer.go for optimization behavior control
// Impact: Higher values cause more aggressive optimization changes.
// Default 0.7 provides assertive optimization while maintaining stability.
OptimizerAggressiveness float64
@ -1070,35 +1070,35 @@ type AudioConfigConstants struct {
FrontendDebugIntervalMS int
// Process Monitor Constants - System resource monitoring configuration
// Used in: monitor_process.go for system resource tracking
// Used in: process_monitor.go for system resource tracking
// Impact: Controls process monitoring behavior and system compatibility
// ProcessMonitorDefaultMemoryGB defines default memory size for fallback calculations.
// Used in: monitor_process.go when system memory cannot be detected
// Used in: process_monitor.go when system memory cannot be detected
// Impact: Should approximate actual system memory for accurate calculations.
// Default 4GB provides reasonable fallback for typical embedded systems.
ProcessMonitorDefaultMemoryGB int
// ProcessMonitorKBToBytes defines conversion factor from kilobytes to bytes.
// Used in: monitor_process.go for memory unit conversions
// Used in: process_monitor.go for memory unit conversions
// Impact: Must be 1024 for accurate binary unit conversions.
// Default 1024 provides standard binary conversion factor.
ProcessMonitorKBToBytes int
// ProcessMonitorDefaultClockHz defines default system clock frequency.
// Used in: monitor_process.go for CPU time calculations on ARM systems
// Used in: process_monitor.go for CPU time calculations on ARM systems
// Impact: Should match actual system clock for accurate CPU measurements.
// Default 250.0 Hz matches typical ARM embedded system configuration.
ProcessMonitorDefaultClockHz float64
// ProcessMonitorFallbackClockHz defines fallback clock frequency.
// Used in: monitor_process.go when system clock cannot be detected
// Used in: process_monitor.go when system clock cannot be detected
// Impact: Provides fallback for CPU time calculations.
// Default 1000.0 Hz provides reasonable fallback clock frequency.
ProcessMonitorFallbackClockHz float64
// ProcessMonitorTraditionalHz defines traditional system clock frequency.
// Used in: monitor_process.go for legacy system compatibility
// Used in: process_monitor.go for legacy system compatibility
// Impact: Supports older systems with traditional clock frequencies.
// Default 100.0 Hz provides compatibility with traditional Unix systems.
ProcessMonitorTraditionalHz float64
@ -1148,11 +1148,11 @@ type AudioConfigConstants struct {
OutputStreamingFrameIntervalMS int
// IPC Constants - Inter-Process Communication configuration
// Used in: ipc_output.go for IPC buffer management
// Used in: ipc.go for IPC buffer management
// Impact: Controls IPC buffer sizing and performance
// IPCInitialBufferFrames defines initial buffer size for IPC operations.
// Used in: ipc_output.go for initial IPC buffer allocation
// Used in: ipc.go for initial IPC buffer allocation
// Impact: Larger buffers reduce allocation overhead but increase memory usage.
// Default 500 frames provides good initial buffer size for IPC operations.
IPCInitialBufferFrames int
@ -1210,13 +1210,13 @@ type AudioConfigConstants struct {
// Impact: Controls socket file naming and IPC connection endpoints
// InputSocketName defines the socket file name for audio input IPC.
// Used in: ipc_input.go for microphone input communication
// Used in: input_ipc.go for microphone input communication
// Impact: Must be unique to prevent conflicts with other audio sockets.
// Default "audio_input.sock" provides clear identification for input socket.
InputSocketName string
// OutputSocketName defines the socket file name for audio output IPC.
// Used in: ipc_output.go for audio output communication
// Used in: ipc.go for audio output communication
// Impact: Must be unique to prevent conflicts with other audio sockets.
// Default "audio_output.sock" provides clear identification for output socket.
OutputSocketName string
@ -1226,19 +1226,19 @@ type AudioConfigConstants struct {
// Impact: Provides consistent component identification across logs
// AudioInputComponentName defines component name for audio input logging.
// Used in: ipc_input.go and related input processing components
// Used in: input_ipc.go and related input processing components
// Impact: Ensures consistent logging identification for input components.
// Default "audio-input" provides clear component identification.
AudioInputComponentName string
// AudioOutputComponentName defines component name for audio output logging.
// Used in: ipc_output.go and related output processing components
// Used in: ipc.go and related output processing components
// Impact: Ensures consistent logging identification for output components.
// Default "audio-output" provides clear component identification.
AudioOutputComponentName string
// AudioServerComponentName defines component name for audio server logging.
// Used in: output_supervisor.go and server management components
// Used in: supervisor.go and server management components
// Impact: Ensures consistent logging identification for server components.
// Default "audio-server" provides clear component identification.
AudioServerComponentName string
@ -1318,17 +1318,17 @@ type AudioConfigConstants struct {
HitRateCalculationBase float64
// Validation Constants - Configuration for input validation
// Used in: core_validation.go for parameter validation
// Used in: validation.go for parameter validation
// Impact: Controls validation thresholds and limits
// MaxLatency defines maximum allowed latency for audio processing.
// Used in: core_validation.go for latency validation
// Used in: validation.go for latency validation
// Impact: Controls maximum acceptable latency before optimization triggers.
// Default 200ms provides reasonable upper bound for real-time audio.
MaxLatency time.Duration
// MinMetricsUpdateInterval defines minimum allowed metrics update interval.
// Used in: core_validation.go for metrics interval validation
// Used in: validation.go for metrics interval validation
// Impact: Prevents excessive metrics updates that could impact performance.
// Default 100ms provides reasonable minimum update frequency.
MinMetricsUpdateInterval time.Duration
@ -1340,7 +1340,7 @@ type AudioConfigConstants struct {
MaxMetricsUpdateInterval time.Duration
// MinSampleRate defines minimum allowed audio sample rate.
// Used in: core_validation.go for sample rate validation
// Used in: validation.go for sample rate validation
// Impact: Ensures sample rate is sufficient for audio quality.
// Default 8000Hz provides minimum for voice communication.
MinSampleRate int
@ -1352,7 +1352,7 @@ type AudioConfigConstants struct {
MaxSampleRate int
// MaxChannels defines maximum allowed audio channels.
// Used in: core_validation.go for channel count validation
// Used in: validation.go for channel count validation
// Impact: Prevents excessive channel counts that could impact performance.
// Default 8 channels provides reasonable upper bound for multi-channel audio.
MaxChannels int
@ -1887,11 +1887,11 @@ func DefaultAudioConfig() *AudioConfigConstants {
MaxValidClockTicks: 1000, // Maximum valid clock ticks
// Performance Tuning - Thresholds for adaptive performance management
// Used in: monitor_adaptive_optimizer.go, quality_manager.go for performance scaling
// Used in: adaptive_optimizer.go, quality_manager.go for performance scaling
// Impact: Controls when system switches between performance modes
// CPUFactor defines weight of CPU usage in performance calculations (0.7).
// Used in: monitor_adaptive_optimizer.go for weighted performance scoring
// Used in: adaptive_optimizer.go for weighted performance scoring
// Impact: Higher values make CPU usage more influential in decisions
// Default 0.7 (70%) emphasizes CPU as primary performance bottleneck
CPUFactor: 0.7,

View File

@ -1,169 +0,0 @@
package audio
import (
"context"
"errors"
"github.com/coder/websocket"
"github.com/rs/zerolog"
)
// AudioControlService provides core audio control operations
type AudioControlService struct {
sessionProvider SessionProvider
logger *zerolog.Logger
}
// NewAudioControlService creates a new audio control service
func NewAudioControlService(sessionProvider SessionProvider, logger *zerolog.Logger) *AudioControlService {
return &AudioControlService{
sessionProvider: sessionProvider,
logger: logger,
}
}
// MuteAudio sets the audio mute state
func (s *AudioControlService) MuteAudio(muted bool) error {
SetAudioMuted(muted)
SetAudioRelayMuted(muted)
// Broadcast audio mute state change via WebSocket
broadcaster := GetAudioEventBroadcaster()
broadcaster.BroadcastAudioDeviceChanged(!muted, "audio_mute_changed")
return nil
}
// StartMicrophone starts the microphone input
func (s *AudioControlService) StartMicrophone() error {
if !s.sessionProvider.IsSessionActive() {
return errors.New("no active session")
}
audioInputManager := s.sessionProvider.GetAudioInputManager()
if audioInputManager == nil {
return errors.New("audio input manager not available")
}
if audioInputManager.IsRunning() {
s.logger.Info().Msg("microphone already running")
return nil
}
if err := audioInputManager.Start(); err != nil {
s.logger.Error().Err(err).Msg("failed to start microphone")
return err
}
s.logger.Info().Msg("microphone started successfully")
return nil
}
// MuteMicrophone sets the microphone mute state
func (s *AudioControlService) MuteMicrophone(muted bool) error {
// Set microphone mute state using the audio relay
SetAudioRelayMuted(muted)
// Broadcast microphone mute state change via WebSocket
broadcaster := GetAudioEventBroadcaster()
broadcaster.BroadcastAudioDeviceChanged(!muted, "microphone_mute_changed")
s.logger.Info().Bool("muted", muted).Msg("microphone mute state updated")
return nil
}
// ResetMicrophone resets the microphone
func (s *AudioControlService) ResetMicrophone() error {
if !s.sessionProvider.IsSessionActive() {
return errors.New("no active session")
}
audioInputManager := s.sessionProvider.GetAudioInputManager()
if audioInputManager == nil {
return errors.New("audio input manager not available")
}
if audioInputManager.IsRunning() {
audioInputManager.Stop()
s.logger.Info().Msg("stopped microphone for reset")
}
if err := audioInputManager.Start(); err != nil {
s.logger.Error().Err(err).Msg("failed to restart microphone during reset")
return err
}
s.logger.Info().Msg("microphone reset successfully")
return nil
}
// GetMicrophoneStatus returns the current microphone status
func (s *AudioControlService) GetMicrophoneStatus() map[string]interface{} {
if s.sessionProvider == nil {
return map[string]interface{}{
"error": "no session provider",
}
}
if !s.sessionProvider.IsSessionActive() {
return map[string]interface{}{
"error": "no active session",
}
}
audioInputManager := s.sessionProvider.GetAudioInputManager()
if audioInputManager == nil {
return map[string]interface{}{
"error": "no audio input manager",
}
}
return map[string]interface{}{
"running": audioInputManager.IsRunning(),
"ready": audioInputManager.IsReady(),
}
}
// SetAudioQuality sets the audio output quality
func (s *AudioControlService) SetAudioQuality(quality AudioQuality) {
SetAudioQuality(quality)
}
// SetMicrophoneQuality sets the microphone input quality
func (s *AudioControlService) SetMicrophoneQuality(quality AudioQuality) {
SetMicrophoneQuality(quality)
}
// GetAudioQualityPresets returns available audio quality presets
func (s *AudioControlService) GetAudioQualityPresets() map[AudioQuality]AudioConfig {
return GetAudioQualityPresets()
}
// GetMicrophoneQualityPresets returns available microphone quality presets
func (s *AudioControlService) GetMicrophoneQualityPresets() map[AudioQuality]AudioConfig {
return GetMicrophoneQualityPresets()
}
// GetCurrentAudioQuality returns the current audio quality configuration
func (s *AudioControlService) GetCurrentAudioQuality() AudioConfig {
return GetAudioConfig()
}
// GetCurrentMicrophoneQuality returns the current microphone quality configuration
func (s *AudioControlService) GetCurrentMicrophoneQuality() AudioConfig {
return GetMicrophoneConfig()
}
// SubscribeToAudioEvents subscribes to audio events via WebSocket
func (s *AudioControlService) SubscribeToAudioEvents(connectionID string, wsCon *websocket.Conn, runCtx context.Context, logger *zerolog.Logger) {
logger.Info().Msg("client subscribing to audio events")
broadcaster := GetAudioEventBroadcaster()
broadcaster.Subscribe(connectionID, wsCon, runCtx, logger)
}
// UnsubscribeFromAudioEvents unsubscribes from audio events
func (s *AudioControlService) UnsubscribeFromAudioEvents(connectionID string, logger *zerolog.Logger) {
logger.Info().Str("connection_id", connectionID).Msg("client unsubscribing from audio events")
broadcaster := GetAudioEventBroadcaster()
broadcaster.Unsubscribe(connectionID)
}

View File

@ -15,12 +15,6 @@ import (
"github.com/rs/zerolog"
)
// Component name constants for logging
const (
AudioInputServerComponent = "audio-input-server"
AudioInputClientComponent = "audio-input-client"
)
// Constants are now defined in unified_ipc.go
var (
maxFrameSize = GetConfig().MaxFrameSize // Maximum Opus frame size

View File

@ -9,11 +9,6 @@ import (
"github.com/rs/zerolog"
)
// Component name constant for logging
const (
AudioInputIPCComponent = "audio-input-ipc"
)
// AudioInputIPCManager manages microphone input using IPC when enabled
type AudioInputIPCManager struct {
metrics AudioInputMetrics

View File

@ -25,6 +25,7 @@ import (
// This should be called from main() when the subprocess is detected
func RunAudioInputServer() error {
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
logger.Debug().Msg("audio input server subprocess starting")
// Parse OPUS configuration from environment variables
bitrate, complexity, vbr, signalType, bandwidth, dtx := parseOpusConfig()
@ -45,7 +46,7 @@ func RunAudioInputServer() error {
// Continue without playback - input functionality doesn't require it
} else {
defer CGOAudioPlaybackClose()
logger.Info().Msg("CGO audio playback initialized successfully")
logger.Debug().Msg("CGO audio playback initialized successfully")
}
// Create and start the IPC server
@ -62,7 +63,7 @@ func RunAudioInputServer() error {
return err
}
logger.Info().Msg("audio input server started, waiting for connections")
logger.Debug().Msg("audio input server started, waiting for connections")
// Set up signal handling for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
@ -76,13 +77,16 @@ func RunAudioInputServer() error {
case sig := <-sigChan:
logger.Info().Str("signal", sig.String()).Msg("received shutdown signal")
case <-ctx.Done():
logger.Debug().Msg("context cancelled")
}
// Graceful shutdown
logger.Debug().Msg("shutting down audio input server")
server.Stop()
// Give some time for cleanup
time.Sleep(GetConfig().DefaultSleepDuration)
logger.Debug().Msg("audio input server subprocess stopped")
return nil
}

View File

@ -117,12 +117,14 @@ func NewLatencyMonitor(config LatencyConfig, logger zerolog.Logger) *LatencyMoni
func (lm *LatencyMonitor) Start() {
lm.wg.Add(1)
go lm.monitoringLoop()
lm.logger.Debug().Msg("latency monitor started")
}
// Stop stops the latency monitor
func (lm *LatencyMonitor) Stop() {
lm.cancel()
lm.wg.Wait()
lm.logger.Debug().Msg("latency monitor stopped")
}
// RecordLatency records a new latency measurement
@ -265,11 +267,13 @@ func (lm *LatencyMonitor) runOptimization() {
adaptiveThreshold := time.Duration(float64(lm.config.TargetLatency.Nanoseconds()) * (1.0 + lm.config.AdaptiveThreshold))
if metrics.Average > adaptiveThreshold {
needsOptimization = true
lm.logger.Debug().Dur("average_latency", metrics.Average).Dur("threshold", adaptiveThreshold).Msg("average latency above adaptive threshold")
}
// Check if jitter is too high
if metrics.Jitter > lm.config.JitterThreshold {
needsOptimization = true
lm.logger.Debug().Dur("jitter", metrics.Jitter).Dur("threshold", lm.config.JitterThreshold).Msg("jitter above threshold")
}
if needsOptimization {
@ -286,6 +290,8 @@ func (lm *LatencyMonitor) runOptimization() {
lm.logger.Error().Err(err).Msg("optimization callback failed")
}
}
lm.logger.Debug().Interface("metrics", metrics).Msg("latency optimization triggered")
}
}

View File

@ -8,11 +8,6 @@ import (
"github.com/jetkvm/kvm/internal/logging"
)
// Component name constant for logging
const (
AudioInputManagerComponent = "audio-input-manager"
)
// AudioInputMetrics holds metrics for microphone input
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
type AudioInputMetrics struct {

View File

@ -1,333 +0,0 @@
package audio
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog"
)
// LatencyMonitor tracks and optimizes audio latency in real-time
type LatencyMonitor struct {
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
currentLatency int64 // Current latency in nanoseconds (atomic)
averageLatency int64 // Rolling average latency in nanoseconds (atomic)
minLatency int64 // Minimum observed latency in nanoseconds (atomic)
maxLatency int64 // Maximum observed latency in nanoseconds (atomic)
latencySamples int64 // Number of latency samples collected (atomic)
jitterAccumulator int64 // Accumulated jitter for variance calculation (atomic)
lastOptimization int64 // Timestamp of last optimization in nanoseconds (atomic)
config LatencyConfig
logger zerolog.Logger
// Control channels
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Optimization callbacks
optimizationCallbacks []OptimizationCallback
mutex sync.RWMutex
// Performance tracking
latencyHistory []LatencyMeasurement
historyMutex sync.RWMutex
}
// LatencyConfig holds configuration for latency monitoring
type LatencyConfig struct {
TargetLatency time.Duration // Target latency to maintain
MaxLatency time.Duration // Maximum acceptable latency
OptimizationInterval time.Duration // How often to run optimization
HistorySize int // Number of latency measurements to keep
JitterThreshold time.Duration // Jitter threshold for optimization
AdaptiveThreshold float64 // Threshold for adaptive adjustments (0.0-1.0)
}
// LatencyMeasurement represents a single latency measurement
type LatencyMeasurement struct {
Timestamp time.Time
Latency time.Duration
Jitter time.Duration
Source string // Source of the measurement (e.g., "input", "output", "processing")
}
// OptimizationCallback is called when latency optimization is triggered
type OptimizationCallback func(metrics LatencyMetrics) error
// LatencyMetrics provides comprehensive latency statistics
type LatencyMetrics struct {
Current time.Duration
Average time.Duration
Min time.Duration
Max time.Duration
Jitter time.Duration
SampleCount int64
Trend LatencyTrend
}
// LatencyTrend indicates the direction of latency changes
type LatencyTrend int
const (
LatencyTrendStable LatencyTrend = iota
LatencyTrendIncreasing
LatencyTrendDecreasing
LatencyTrendVolatile
)
// DefaultLatencyConfig returns a sensible default configuration
func DefaultLatencyConfig() LatencyConfig {
config := GetConfig()
return LatencyConfig{
TargetLatency: config.LatencyMonitorTarget,
MaxLatency: config.MaxLatencyThreshold,
OptimizationInterval: config.LatencyOptimizationInterval,
HistorySize: config.LatencyHistorySize,
JitterThreshold: config.JitterThreshold,
AdaptiveThreshold: config.LatencyAdaptiveThreshold,
}
}
// NewLatencyMonitor creates a new latency monitoring system
func NewLatencyMonitor(config LatencyConfig, logger zerolog.Logger) *LatencyMonitor {
// Validate latency configuration
if err := ValidateLatencyConfig(config); err != nil {
// Log validation error and use default configuration
logger.Error().Err(err).Msg("Invalid latency configuration provided, using defaults")
config = DefaultLatencyConfig()
}
ctx, cancel := context.WithCancel(context.Background())
return &LatencyMonitor{
config: config,
logger: logger.With().Str("component", "latency-monitor").Logger(),
ctx: ctx,
cancel: cancel,
latencyHistory: make([]LatencyMeasurement, 0, config.HistorySize),
minLatency: int64(time.Hour), // Initialize to high value
}
}
// Start begins latency monitoring and optimization
func (lm *LatencyMonitor) Start() {
lm.wg.Add(1)
go lm.monitoringLoop()
}
// Stop stops the latency monitor
func (lm *LatencyMonitor) Stop() {
lm.cancel()
lm.wg.Wait()
}
// RecordLatency records a new latency measurement
func (lm *LatencyMonitor) RecordLatency(latency time.Duration, source string) {
now := time.Now()
latencyNanos := latency.Nanoseconds()
// Update atomic counters
atomic.StoreInt64(&lm.currentLatency, latencyNanos)
atomic.AddInt64(&lm.latencySamples, 1)
// Update min/max
for {
oldMin := atomic.LoadInt64(&lm.minLatency)
if latencyNanos >= oldMin || atomic.CompareAndSwapInt64(&lm.minLatency, oldMin, latencyNanos) {
break
}
}
for {
oldMax := atomic.LoadInt64(&lm.maxLatency)
if latencyNanos <= oldMax || atomic.CompareAndSwapInt64(&lm.maxLatency, oldMax, latencyNanos) {
break
}
}
// Update rolling average using exponential moving average
oldAvg := atomic.LoadInt64(&lm.averageLatency)
newAvg := oldAvg + (latencyNanos-oldAvg)/10 // Alpha = 0.1
atomic.StoreInt64(&lm.averageLatency, newAvg)
// Calculate jitter (difference from average)
jitter := latencyNanos - newAvg
if jitter < 0 {
jitter = -jitter
}
atomic.AddInt64(&lm.jitterAccumulator, jitter)
// Store in history
lm.historyMutex.Lock()
measurement := LatencyMeasurement{
Timestamp: now,
Latency: latency,
Jitter: time.Duration(jitter),
Source: source,
}
if len(lm.latencyHistory) >= lm.config.HistorySize {
// Remove oldest measurement
copy(lm.latencyHistory, lm.latencyHistory[1:])
lm.latencyHistory[len(lm.latencyHistory)-1] = measurement
} else {
lm.latencyHistory = append(lm.latencyHistory, measurement)
}
lm.historyMutex.Unlock()
}
// GetMetrics returns current latency metrics
func (lm *LatencyMonitor) GetMetrics() LatencyMetrics {
current := atomic.LoadInt64(&lm.currentLatency)
average := atomic.LoadInt64(&lm.averageLatency)
min := atomic.LoadInt64(&lm.minLatency)
max := atomic.LoadInt64(&lm.maxLatency)
samples := atomic.LoadInt64(&lm.latencySamples)
jitterSum := atomic.LoadInt64(&lm.jitterAccumulator)
var jitter time.Duration
if samples > 0 {
jitter = time.Duration(jitterSum / samples)
}
return LatencyMetrics{
Current: time.Duration(current),
Average: time.Duration(average),
Min: time.Duration(min),
Max: time.Duration(max),
Jitter: jitter,
SampleCount: samples,
Trend: lm.calculateTrend(),
}
}
// AddOptimizationCallback adds a callback for latency optimization
func (lm *LatencyMonitor) AddOptimizationCallback(callback OptimizationCallback) {
lm.mutex.Lock()
lm.optimizationCallbacks = append(lm.optimizationCallbacks, callback)
lm.mutex.Unlock()
}
// monitoringLoop runs the main monitoring and optimization loop
func (lm *LatencyMonitor) monitoringLoop() {
defer lm.wg.Done()
ticker := time.NewTicker(lm.config.OptimizationInterval)
defer ticker.Stop()
for {
select {
case <-lm.ctx.Done():
return
case <-ticker.C:
lm.runOptimization()
}
}
}
// runOptimization checks if optimization is needed and triggers callbacks with threshold validation.
//
// Validation Rules:
// - Current latency must not exceed MaxLatency (default: 200ms)
// - Average latency checked against adaptive threshold: TargetLatency * (1 + AdaptiveThreshold)
// - Jitter must not exceed JitterThreshold (default: 20ms)
// - All latency values must be non-negative durations
//
// Optimization Triggers:
// - Current latency > MaxLatency: Immediate optimization needed
// - Average latency > adaptive threshold: Gradual optimization needed
// - Jitter > JitterThreshold: Stability optimization needed
//
// Threshold Calculations:
// - Adaptive threshold = TargetLatency * (1.0 + AdaptiveThreshold)
// - Default: 50ms * (1.0 + 0.8) = 90ms adaptive threshold
// - Provides buffer above target before triggering optimization
//
// The function ensures real-time audio performance by monitoring multiple
// latency metrics and triggering optimization callbacks when thresholds are exceeded.
func (lm *LatencyMonitor) runOptimization() {
metrics := lm.GetMetrics()
// Check if optimization is needed
needsOptimization := false
// Check if current latency exceeds threshold
if metrics.Current > lm.config.MaxLatency {
needsOptimization = true
lm.logger.Warn().Dur("current_latency", metrics.Current).Dur("max_latency", lm.config.MaxLatency).Msg("latency exceeds maximum threshold")
}
// Check if average latency is above adaptive threshold
adaptiveThreshold := time.Duration(float64(lm.config.TargetLatency.Nanoseconds()) * (1.0 + lm.config.AdaptiveThreshold))
if metrics.Average > adaptiveThreshold {
needsOptimization = true
}
// Check if jitter is too high
if metrics.Jitter > lm.config.JitterThreshold {
needsOptimization = true
}
if needsOptimization {
atomic.StoreInt64(&lm.lastOptimization, time.Now().UnixNano())
// Run optimization callbacks
lm.mutex.RLock()
callbacks := make([]OptimizationCallback, len(lm.optimizationCallbacks))
copy(callbacks, lm.optimizationCallbacks)
lm.mutex.RUnlock()
for _, callback := range callbacks {
if err := callback(metrics); err != nil {
lm.logger.Error().Err(err).Msg("optimization callback failed")
}
}
}
}
// calculateTrend analyzes recent latency measurements to determine trend
func (lm *LatencyMonitor) calculateTrend() LatencyTrend {
lm.historyMutex.RLock()
defer lm.historyMutex.RUnlock()
if len(lm.latencyHistory) < 10 {
return LatencyTrendStable
}
// Analyze last 10 measurements
recentMeasurements := lm.latencyHistory[len(lm.latencyHistory)-10:]
var increasing, decreasing int
for i := 1; i < len(recentMeasurements); i++ {
if recentMeasurements[i].Latency > recentMeasurements[i-1].Latency {
increasing++
} else if recentMeasurements[i].Latency < recentMeasurements[i-1].Latency {
decreasing++
}
}
// Determine trend based on direction changes
if increasing > 6 {
return LatencyTrendIncreasing
} else if decreasing > 6 {
return LatencyTrendDecreasing
} else if increasing+decreasing > 7 {
return LatencyTrendVolatile
}
return LatencyTrendStable
}
// GetLatencyHistory returns a copy of recent latency measurements
func (lm *LatencyMonitor) GetLatencyHistory() []LatencyMeasurement {
lm.historyMutex.RLock()
defer lm.historyMutex.RUnlock()
history := make([]LatencyMeasurement, len(lm.latencyHistory))
copy(history, lm.latencyHistory)
return history
}

View File

@ -0,0 +1,111 @@
package audio
import "time"
// Naming Standards Documentation
// This file documents the standardized naming conventions for audio components
// to ensure consistency across the entire audio system.
/*
STANDARDIZED NAMING CONVENTIONS:
1. COMPONENT HIERARCHY:
- Manager: High-level component that orchestrates multiple subsystems
- Supervisor: Process lifecycle management (start/stop/restart processes)
- Server: IPC server that handles incoming connections
- Client: IPC client that connects to servers
- Streamer: High-performance streaming component
2. NAMING PATTERNS:
Input Components:
- AudioInputManager (replaces: AudioInputManager)
- AudioInputSupervisor (replaces: AudioInputSupervisor)
- AudioInputServer (replaces: AudioInputServer)
- AudioInputClient (replaces: AudioInputClient)
- AudioInputStreamer (new: for consistency with OutputStreamer)
Output Components:
- AudioOutputSupervisor (replaces: AudioOutputSupervisor)
- AudioOutputServer (replaces: AudioOutputServer)
- AudioOutputClient (replaces: AudioOutputClient)
3. IPC NAMING:
- AudioInputIPCManager (replaces: AudioInputIPCManager)
- AudioOutputIPCManager (new: for consistency)
4. CONFIGURATION NAMING:
- InputIPCConfig (replaces: InputIPCConfig)
- OutputIPCConfig (new: for consistency)
5. MESSAGE NAMING:
- InputIPCMessage (replaces: InputIPCMessage)
- OutputIPCMessage (replaces: OutputIPCMessage)
- InputMessageType (replaces: InputMessageType)
- OutputMessageType (replaces: OutputMessageType)
ISSUES IDENTIFIED:
1. Missing AudioOutputIPCManager for symmetry
2. Missing OutputIPCConfig for consistency
3. Component names in logging should be standardized
IMPLEMENTATION PLAN:
1. Create AudioOutputIPCManager for symmetry
2. Standardize all component logging names
3. Update all references consistently
*/
// Component name constants for consistent logging
const (
// Input component names
AudioInputManagerComponent = "audio-input-manager"
AudioInputSupervisorComponent = "audio-input-supervisor"
AudioInputServerComponent = "audio-input-server"
AudioInputClientComponent = "audio-input-client"
AudioInputIPCComponent = "audio-input-ipc"
// Output component names
AudioOutputSupervisorComponent = "audio-output-supervisor"
AudioOutputServerComponent = "audio-output-server"
AudioOutputClientComponent = "audio-output-client"
AudioOutputIPCComponent = "audio-output-ipc"
// Common component names
AudioRelayComponent = "audio-relay"
AudioEventsComponent = "audio-events"
AudioMetricsComponent = "audio-metrics"
)
// Interface definitions for consistent component behavior
type AudioManagerInterface interface {
Start() error
Stop()
IsRunning() bool
IsReady() bool
GetMetrics() interface{}
}
type AudioSupervisorInterface interface {
Start() error
Stop() error
IsRunning() bool
GetProcessPID() int
}
type AudioServerInterface interface {
Start() error
Stop()
Close() error
}
type AudioClientInterface interface {
Connect() error
Disconnect()
IsConnected() bool
Close() error
}
type AudioStreamerInterface interface {
Start() error
Stop()
GetStats() (processed, dropped int64, avgProcessingTime time.Duration)
}

View File

@ -8,11 +8,6 @@ import (
"github.com/jetkvm/kvm/internal/logging"
)
// Component name constant for logging
const (
AudioOutputIPCComponent = "audio-output-ipc"
)
// AudioOutputMetrics represents metrics for audio output operations
type AudioOutputMetrics struct {
// Atomic int64 field first for proper ARM32 alignment
@ -213,6 +208,8 @@ func (aom *AudioOutputIPCManager) SendConfig(config OutputIPCConfig) error {
return fmt.Errorf("output configuration validation failed: %w", err)
}
// Note: AudioOutputServer doesn't have SendConfig method yet
// This is a placeholder for future implementation
aom.logger.Info().Interface("config", config).Msg("configuration received")
return nil
}

View File

@ -16,6 +16,7 @@ import (
// This should be called from main() when the subprocess is detected
func RunAudioOutputServer() error {
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-server").Logger()
logger.Debug().Msg("audio output server subprocess starting")
// Parse OPUS configuration from environment variables
bitrate, complexity, vbr, signalType, bandwidth, dtx := parseOpusConfig()
@ -50,7 +51,7 @@ func RunAudioOutputServer() error {
return err
}
logger.Info().Msg("audio output server started, waiting for connections")
logger.Debug().Msg("audio output server started, waiting for connections")
// Set up signal handling for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
@ -64,13 +65,16 @@ func RunAudioOutputServer() error {
case sig := <-sigChan:
logger.Info().Str("signal", sig.String()).Msg("received shutdown signal")
case <-ctx.Done():
logger.Debug().Msg("context cancelled")
}
// Graceful shutdown
logger.Debug().Msg("shutting down audio output server")
StopNonBlockingAudioStreaming()
// Give some time for cleanup
time.Sleep(GetConfig().DefaultSleepDuration)
logger.Debug().Msg("audio output server subprocess stopped")
return nil
}

View File

@ -15,11 +15,6 @@ import (
"github.com/rs/zerolog"
)
// Component name constants for logging
const (
AudioOutputSupervisorComponent = "audio-output-supervisor"
)
// Restart configuration is now retrieved from centralized config
func getMaxRestartAttempts() int {
return GetConfig().MaxRestartAttempts

View File

@ -368,6 +368,7 @@ func ValidateFrameDuration(duration time.Duration) error {
cache := GetCachedConfig()
// Convert frameSize (samples) to duration for comparison
// Note: This calculation should match how frameSize is converted to duration elsewhere
cachedFrameSize := int(cache.frameSize.Load())
cachedSampleRate := int(cache.sampleRate.Load())
@ -475,6 +476,9 @@ func ValidateAudioConfigConstants(config *AudioConfigConstants) error {
return nil
}
// Note: We're transitioning from individual cached values to using AudioConfigCache
// for better consistency and reduced maintenance overhead
// Global variable for backward compatibility
var cachedMaxFrameSize int

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,293 @@
package usbgadget
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
)
// UsbGadgetInterface defines the interface for USB gadget operations
// This allows for mocking in tests and separating hardware operations from business logic
type UsbGadgetInterface interface {
// Configuration methods
Init() error
UpdateGadgetConfig() error
SetGadgetConfig(config *Config)
SetGadgetDevices(devices *Devices)
OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool)
// Hardware control methods
RebindUsb(ignoreUnbindError bool) error
IsUDCBound() (bool, error)
BindUDC() error
UnbindUDC() error
// HID file management
PreOpenHidFiles()
CloseHidFiles()
// Transaction methods
WithTransaction(fn func() error) error
WithTransactionTimeout(fn func() error, timeout time.Duration) error
// Path methods
GetConfigPath(itemKey string) (string, error)
GetPath(itemKey string) (string, error)
// Input methods (matching actual UsbGadget implementation)
KeyboardReport(modifier uint8, keys []uint8) error
AbsMouseReport(x, y int, buttons uint8) error
AbsMouseWheelReport(wheelY int8) error
RelMouseReport(mx, my int8, buttons uint8) error
}
// Ensure UsbGadget implements the interface
var _ UsbGadgetInterface = (*UsbGadget)(nil)
// MockUsbGadget provides a mock implementation for testing
type MockUsbGadget struct {
name string
enabledDevices Devices
customConfig Config
log *zerolog.Logger
// Mock state
initCalled bool
updateConfigCalled bool
rebindCalled bool
udcBound bool
hidFilesOpen bool
transactionCount int
// Mock behavior controls
ShouldFailInit bool
ShouldFailUpdateConfig bool
ShouldFailRebind bool
ShouldFailUDCBind bool
InitDelay time.Duration
UpdateConfigDelay time.Duration
RebindDelay time.Duration
}
// NewMockUsbGadget creates a new mock USB gadget for testing
func NewMockUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *MockUsbGadget {
if enabledDevices == nil {
enabledDevices = &defaultUsbGadgetDevices
}
if config == nil {
config = &Config{isEmpty: true}
}
if logger == nil {
logger = defaultLogger
}
return &MockUsbGadget{
name: name,
enabledDevices: *enabledDevices,
customConfig: *config,
log: logger,
udcBound: false,
hidFilesOpen: false,
}
}
// Init mocks USB gadget initialization
func (m *MockUsbGadget) Init() error {
if m.InitDelay > 0 {
time.Sleep(m.InitDelay)
}
if m.ShouldFailInit {
return m.logError("mock init failure", nil)
}
m.initCalled = true
m.udcBound = true
m.log.Info().Msg("mock USB gadget initialized")
return nil
}
// UpdateGadgetConfig mocks gadget configuration update
func (m *MockUsbGadget) UpdateGadgetConfig() error {
if m.UpdateConfigDelay > 0 {
time.Sleep(m.UpdateConfigDelay)
}
if m.ShouldFailUpdateConfig {
return m.logError("mock update config failure", nil)
}
m.updateConfigCalled = true
m.log.Info().Msg("mock USB gadget config updated")
return nil
}
// SetGadgetConfig mocks setting gadget configuration
func (m *MockUsbGadget) SetGadgetConfig(config *Config) {
if config != nil {
m.customConfig = *config
}
}
// SetGadgetDevices mocks setting enabled devices
func (m *MockUsbGadget) SetGadgetDevices(devices *Devices) {
if devices != nil {
m.enabledDevices = *devices
}
}
// OverrideGadgetConfig mocks gadget config override
func (m *MockUsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool) {
m.log.Info().Str("itemKey", itemKey).Str("itemAttr", itemAttr).Str("value", value).Msg("mock override gadget config")
return nil, true
}
// RebindUsb mocks USB rebinding
func (m *MockUsbGadget) RebindUsb(ignoreUnbindError bool) error {
if m.RebindDelay > 0 {
time.Sleep(m.RebindDelay)
}
if m.ShouldFailRebind {
return m.logError("mock rebind failure", nil)
}
m.rebindCalled = true
m.log.Info().Msg("mock USB gadget rebound")
return nil
}
// IsUDCBound mocks UDC binding status check
func (m *MockUsbGadget) IsUDCBound() (bool, error) {
return m.udcBound, nil
}
// BindUDC mocks UDC binding
func (m *MockUsbGadget) BindUDC() error {
if m.ShouldFailUDCBind {
return m.logError("mock UDC bind failure", nil)
}
m.udcBound = true
m.log.Info().Msg("mock UDC bound")
return nil
}
// UnbindUDC mocks UDC unbinding
func (m *MockUsbGadget) UnbindUDC() error {
m.udcBound = false
m.log.Info().Msg("mock UDC unbound")
return nil
}
// PreOpenHidFiles mocks HID file pre-opening
func (m *MockUsbGadget) PreOpenHidFiles() {
m.hidFilesOpen = true
m.log.Info().Msg("mock HID files pre-opened")
}
// CloseHidFiles mocks HID file closing
func (m *MockUsbGadget) CloseHidFiles() {
m.hidFilesOpen = false
m.log.Info().Msg("mock HID files closed")
}
// WithTransaction mocks transaction execution
func (m *MockUsbGadget) WithTransaction(fn func() error) error {
return m.WithTransactionTimeout(fn, 60*time.Second)
}
// WithTransactionTimeout mocks transaction execution with timeout
func (m *MockUsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
m.transactionCount++
m.log.Info().Int("transactionCount", m.transactionCount).Msg("mock transaction started")
// Execute the function in a mock transaction context
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- fn()
}()
select {
case err := <-done:
if err != nil {
m.log.Error().Err(err).Msg("mock transaction failed")
} else {
m.log.Info().Msg("mock transaction completed")
}
return err
case <-ctx.Done():
m.log.Error().Dur("timeout", timeout).Msg("mock transaction timed out")
return ctx.Err()
}
}
// GetConfigPath mocks getting configuration path
func (m *MockUsbGadget) GetConfigPath(itemKey string) (string, error) {
return "/mock/config/path/" + itemKey, nil
}
// GetPath mocks getting path
func (m *MockUsbGadget) GetPath(itemKey string) (string, error) {
return "/mock/path/" + itemKey, nil
}
// KeyboardReport mocks keyboard input
func (m *MockUsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
m.log.Debug().Uint8("modifier", modifier).Int("keyCount", len(keys)).Msg("mock keyboard input sent")
return nil
}
// AbsMouseReport mocks absolute mouse input
func (m *MockUsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
m.log.Debug().Int("x", x).Int("y", y).Uint8("buttons", buttons).Msg("mock absolute mouse input sent")
return nil
}
// AbsMouseWheelReport mocks absolute mouse wheel input
func (m *MockUsbGadget) AbsMouseWheelReport(wheelY int8) error {
m.log.Debug().Int8("wheelY", wheelY).Msg("mock absolute mouse wheel input sent")
return nil
}
// RelMouseReport mocks relative mouse input
func (m *MockUsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
m.log.Debug().Int8("mx", mx).Int8("my", my).Uint8("buttons", buttons).Msg("mock relative mouse input sent")
return nil
}
// Helper methods for mock
func (m *MockUsbGadget) logError(msg string, err error) error {
if err == nil {
err = fmt.Errorf("%s", msg)
}
m.log.Error().Err(err).Msg(msg)
return err
}
// Mock state inspection methods for testing
func (m *MockUsbGadget) IsInitCalled() bool {
return m.initCalled
}
func (m *MockUsbGadget) IsUpdateConfigCalled() bool {
return m.updateConfigCalled
}
func (m *MockUsbGadget) IsRebindCalled() bool {
return m.rebindCalled
}
func (m *MockUsbGadget) IsHidFilesOpen() bool {
return m.hidFilesOpen
}
func (m *MockUsbGadget) GetTransactionCount() int {
return m.transactionCount
}
func (m *MockUsbGadget) GetEnabledDevices() Devices {
return m.enabledDevices
}
func (m *MockUsbGadget) GetCustomConfig() Config {
return m.customConfig
}

View File

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

View File

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

View File

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

3
log.go
View File

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

329
native.go
View File

@ -1,255 +1,46 @@
//go:build linux
package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/rs/zerolog"
)
var ctrlSocketConn net.Conn
type CtrlAction struct {
Action string `json:"action"`
Seq int32 `json:"seq,omitempty"`
Params map[string]any `json:"params,omitempty"`
type nativeOutput struct {
logger *zerolog.Logger
}
type CtrlResponse struct {
Seq int32 `json:"seq,omitempty"`
Error string `json:"error,omitempty"`
Errno int32 `json:"errno,omitempty"`
Result map[string]any `json:"result,omitempty"`
Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
func (n *nativeOutput) Write(p []byte) (int, error) {
n.logger.Debug().Str("output", string(p)).Msg("native binary output")
return len(p), nil
}
type EventHandler func(event CtrlResponse)
var seq int32 = 1
var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
)
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
lock.Lock()
defer lock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
cmd := exec.Command(binaryPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
}
cmd.Stdout = &nativeOutput{logger: nativeLogger}
cmd.Stderr = &nativeOutput{logger: nativeLogger}
responseChan := make(chan *CtrlResponse)
ongoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
err := cmd.Start()
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
return nil, err
}
scopedLogger := nativeLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending ctrl action")
err = WriteCtrlMessage(jsonData)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
}
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error native response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
func WriteCtrlMessage(message []byte) error {
if ctrlSocketConn == nil {
return fmt.Errorf("ctrl socket not conn ected")
}
_, err := ctrlSocketConn.Write(message)
return err
}
var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener //nolint:unused
var ctrlClientConnected = make(chan struct{})
func waitCtrlClientConnected() {
<-ctrlClientConnected
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-ctrlClientConnected:
scopedLogger.Debug().Msg("ctrl client reconnected")
default:
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
}
go handleClient(conn)
}
}()
return listener
}
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
nativeLogger.Debug().Msg("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
nativeLogger.Debug().Msg("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
scopedLogger.Info().Msg("native ctrl socket client connected")
if ctrlSocketConn != nil {
scopedLogger.Debug().Msg("closing existing native socket connection")
ctrlSocketConn.Close()
}
ctrlSocketConn = conn
// Restore HDMI EDID if applicable
go restoreHdmiEdid()
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
break
}
readMsg := string(readBuf[:n])
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
if ctrlResp.Seq != 0 {
responseChan, ok := ongoingRequests[ctrlResp.Seq]
if ok {
responseChan <- &ctrlResp
}
}
switch ctrlResp.Event {
case "video_input_state":
HandleVideoStateMessage(ctrlResp)
}
}
scopedLogger.Debug().Msg("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
scopedLogger.Info().Msg("native video socket client connected")
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error during read")
return
}
now := time.Now()
sinceLastFrame := now.Sub(lastFrame)
lastFrame = now
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil {
scopedLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
return cmd, nil
}
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
@ -351,87 +142,3 @@ func ExtractAndRunNativeBin() error {
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
return !bytes.Equal(srcHash, dstHash)
}
func getNativeSha256() ([]byte, error) {
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
return nil, err
}
return version, nil
}
func GetNativeVersion() (string, error) {
version, err := getNativeSha256()
if err != nil {
return "", err
}
return strings.TrimSpace(string(version)), nil
}
func ensureBinaryUpdated(destPath string) error {
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil {
return err
}
defer srcFile.Close()
srcHash, err := getNativeSha256()
if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
nativeLogger.Info().
Interface("hash", srcHash).
Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
return err
}
_, err = io.Copy(destFile, srcFile)
destFile.Close()
if err != nil {
return err
}
if srcHash != nil {
err = os.WriteFile(destPath+".sha256", srcHash, 0644)
if err != nil {
return err
}
}
nativeLogger.Info().Msg("jetkvm_native updated")
}
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

View File

@ -1,57 +0,0 @@
//go:build linux
package kvm
import (
"fmt"
"os/exec"
"sync"
"syscall"
"github.com/rs/zerolog"
)
type nativeOutput struct {
mu *sync.Mutex
logger *zerolog.Event
}
func (w *nativeOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
w.logger.Msg(string(p))
return len(p), nil
}
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary in the background
cmd := exec.Command(binaryPath)
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return cmd, nil
}

View File

@ -8,5 +8,9 @@ import (
)
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
return nil, fmt.Errorf("startNativeBinary is only supported on Linux")
}
func ExtractAndRunNativeBin() error {
return fmt.Errorf("ExtractAndRunNativeBin is only supported on Linux")
}

343
native_shared.go Normal file
View File

@ -0,0 +1,343 @@
package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/webrtc/v4/pkg/media"
)
type CtrlAction struct {
Action string `json:"action"`
Seq int32 `json:"seq,omitempty"`
Params map[string]interface{} `json:"params,omitempty"`
}
type CtrlResponse struct {
Seq int32 `json:"seq,omitempty"`
Error string `json:"error,omitempty"`
Errno int32 `json:"errno,omitempty"`
Result map[string]interface{} `json:"result,omitempty"`
Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
type EventHandler func(event CtrlResponse)
var seq int32 = 1
var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{}
var ctrlSocketConn net.Conn
var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener //nolint:unused
var ctrlClientConnected = make(chan struct{})
func waitCtrlClientConnected() {
<-ctrlClientConnected
}
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock()
defer lock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
}
responseChan := make(chan *CtrlResponse)
ongoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := nativeLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending ctrl action")
err = WriteCtrlMessage(jsonData)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
}
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error native response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
func WriteCtrlMessage(message []byte) error {
if ctrlSocketConn == nil {
return fmt.Errorf("ctrl socket not connected")
}
_, err := ctrlSocketConn.Write(message)
return err
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-ctrlClientConnected:
scopedLogger.Debug().Msg("ctrl client reconnected")
default:
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
}
go handleClient(conn)
}
}()
return listener
}
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
nativeLogger.Debug().Msg("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
nativeLogger.Debug().Msg("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
// Lock to OS thread to isolate blocking socket I/O
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
scopedLogger.Info().Msg("native ctrl socket client connected (OS thread locked)")
if ctrlSocketConn != nil {
scopedLogger.Debug().Msg("closing existing native socket connection")
ctrlSocketConn.Close()
}
ctrlSocketConn = conn
// Restore HDMI EDID if applicable
go restoreHdmiEdid()
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
break
}
readMsg := string(readBuf[:n])
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
if ctrlResp.Seq != 0 {
responseChan, ok := ongoingRequests[ctrlResp.Seq]
if ok {
responseChan <- &ctrlResp
}
}
switch ctrlResp.Event {
case "video_input_state":
HandleVideoStateMessage(ctrlResp)
}
}
scopedLogger.Debug().Msg("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
// Lock to OS thread to isolate blocking video I/O
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
scopedLogger.Info().Msg("native video socket client connected (OS thread locked)")
inboundPacket := make([]byte, maxVideoFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error during read")
return
}
now := time.Now()
sinceLastFrame := now.Sub(lastFrame)
lastFrame = now
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil {
scopedLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
return !bytes.Equal(srcHash, dstHash)
}
func getNativeSha256() ([]byte, error) {
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
return nil, err
}
return version, nil
}
func GetNativeVersion() (string, error) {
version, err := getNativeSha256()
if err != nil {
return "", err
}
return strings.TrimSpace(string(version)), nil
}
func ensureBinaryUpdated(destPath string) error {
// Lock to OS thread for file I/O operations
runtime.LockOSThread()
defer runtime.UnlockOSThread()
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil {
return err
}
defer srcFile.Close()
srcHash, err := getNativeSha256()
if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
nativeLogger.Info().
Interface("hash", srcHash).
Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
return err
}
_, err = io.Copy(destFile, srcFile)
destFile.Close()
if err != nil {
return err
}
if srcHash != nil {
err = os.WriteFile(destPath+".sha256", srcHash, 0644)
if err != nil {
return err
}
}
nativeLogger.Info().Msg("jetkvm_native updated")
}
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

65
remote_mount.go Normal file
View File

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

View File

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

View File

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

1407
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 511 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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