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' if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:

View File

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

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: 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") return 0, errors.New("image not mounted")
} }
source := currentVirtualMediaState.Source source := currentVirtualMediaState.Source
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock() virtualMediaStateMutex.RUnlock()
_, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
readLen := int64(len(p))
if off+readLen > mountedImageSize {
readLen = mountedImageSize - off
}
var data []byte
switch source { 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: case HTTP:
return httpRangeReader.ReadAt(p, off) return httpRangeReader.ReadAt(p, off)
default: default:

View File

@ -39,7 +39,8 @@ const (
// should be lower than the websocket response timeout set in cloud-api // should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second CloudOidcRequestTimeout = 10 * time.Second
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud // 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 ( var (
@ -76,23 +77,6 @@ var (
}, },
[]string{"type", "source"}, []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( metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_connection_ping_received_total", Name: "jetkvm_connection_ping_received_total",
@ -100,13 +84,6 @@ var (
}, },
[]string{"type", "source"}, []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( metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration_seconds", 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, ws: c,
IsCloud: isCloudConnection, IsCloud: isCloudConnection,
LocalIP: req.IP, LocalIP: req.IP,
@ -459,23 +446,48 @@ func handleSessionRequest(
return err return err
} }
sd, err := session.ExchangeOffer(req.Sd) sd, err = session.ExchangeOffer(req.Sd)
if err != nil { if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err}) _ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err return err
} }
if currentSession != nil {
// Notify the old session about the takeover
writeJSONRPCEvent("otherSessionConnected", nil, currentSession) writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection peerConn := currentSession.peerConnection
go func() { go func() {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
_ = peerConn.Close() _ = 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.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().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}) _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil return nil
} }

View File

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

View File

@ -30,7 +30,7 @@ const (
// do not call this function directly, use switchToScreenIfDifferent instead // do not call this function directly, use switchToScreenIfDifferent instead
// this function is not thread safe // this function is not thread safe
func switchToScreen(screen string) { 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 { if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return return
@ -39,15 +39,15 @@ func switchToScreen(screen string) {
} }
func lvObjSetState(objName string, state string) (*CtrlResponse, error) { 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) { 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) { 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) { 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 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) { 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) { 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) { 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) { 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) { 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) { 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/Masterminds/semver/v3 v3.4.0
github.com/beevik/ntp v1.4.3 github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13 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/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6 github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1 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/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.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/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7 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/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.22.0
github.com/prometheus/common v0.66.0 github.com/prometheus/common v0.65.0
github.com/prometheus/procfs v0.17.0 github.com/prometheus/procfs v0.16.1
github.com/psanford/httpreadat v0.1.0 github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f 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 github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4 go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.40.0
golang.org/x/net v0.43.0 golang.org/x/net v0.41.0
golang.org/x/sys v0.35.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 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/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // 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/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect github.com/pilebones/go-udev v0.9.1 // indirect
github.com/pion/datachannel v1.5.10 // 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/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // 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/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.16 // indirect github.com/pion/sdp/v3 v3.0.14 // indirect
github.com/pion/srtp/v3 v3.0.7 // indirect github.com/pion/srtp/v3 v3.0.6 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
@ -85,8 +85,7 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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/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 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= 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/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 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= 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-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 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
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/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 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= 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= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= 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-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoNJH4uUmsQ86+0/QqpwEns0NyNLwKv0=
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/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 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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/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 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= 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.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= 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 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= 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/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 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= 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.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= 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 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= 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.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs= github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0= 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 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= 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 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 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.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M= github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= 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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 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.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= 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 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 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.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.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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 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= 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 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 return nil, err
} }
_, err = rpcKeyboardReport(modifier, keys) return nil, rpcKeyboardReport(modifier, keys)
return nil, err
} }
// Direct handler for absolute mouse reports // 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 // 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 // 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])) n := C.jetkvm_audio_read_encode(unsafe.Pointer(&buf[0]))
@ -1082,6 +1084,72 @@ func ReturnBufferToPool(buf []byte) {
ReturnOptimalBuffer(buf) 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 // ReadEncodeWithPooledBuffer reads audio data and encodes it using a buffer from the pool
func ReadEncodeWithPooledBuffer() ([]byte, int, error) { func ReadEncodeWithPooledBuffer() ([]byte, int, error) {
cache := GetCachedConfig() cache := GetCachedConfig()

View File

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

View File

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

View File

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

View File

@ -117,12 +117,14 @@ func NewLatencyMonitor(config LatencyConfig, logger zerolog.Logger) *LatencyMoni
func (lm *LatencyMonitor) Start() { func (lm *LatencyMonitor) Start() {
lm.wg.Add(1) lm.wg.Add(1)
go lm.monitoringLoop() go lm.monitoringLoop()
lm.logger.Debug().Msg("latency monitor started")
} }
// Stop stops the latency monitor // Stop stops the latency monitor
func (lm *LatencyMonitor) Stop() { func (lm *LatencyMonitor) Stop() {
lm.cancel() lm.cancel()
lm.wg.Wait() lm.wg.Wait()
lm.logger.Debug().Msg("latency monitor stopped")
} }
// RecordLatency records a new latency measurement // 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)) adaptiveThreshold := time.Duration(float64(lm.config.TargetLatency.Nanoseconds()) * (1.0 + lm.config.AdaptiveThreshold))
if metrics.Average > adaptiveThreshold { if metrics.Average > adaptiveThreshold {
needsOptimization = true 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 // Check if jitter is too high
if metrics.Jitter > lm.config.JitterThreshold { if metrics.Jitter > lm.config.JitterThreshold {
needsOptimization = true needsOptimization = true
lm.logger.Debug().Dur("jitter", metrics.Jitter).Dur("threshold", lm.config.JitterThreshold).Msg("jitter above threshold")
} }
if needsOptimization { if needsOptimization {
@ -286,6 +290,8 @@ func (lm *LatencyMonitor) runOptimization() {
lm.logger.Error().Err(err).Msg("optimization callback failed") 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" "github.com/jetkvm/kvm/internal/logging"
) )
// Component name constant for logging
const (
AudioInputManagerComponent = "audio-input-manager"
)
// AudioInputMetrics holds metrics for microphone input // AudioInputMetrics holds metrics for microphone input
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment) // Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
type AudioInputMetrics struct { 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" "github.com/jetkvm/kvm/internal/logging"
) )
// Component name constant for logging
const (
AudioOutputIPCComponent = "audio-output-ipc"
)
// AudioOutputMetrics represents metrics for audio output operations // AudioOutputMetrics represents metrics for audio output operations
type AudioOutputMetrics struct { type AudioOutputMetrics struct {
// Atomic int64 field first for proper ARM32 alignment // 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) 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") aom.logger.Info().Interface("config", config).Msg("configuration received")
return nil return nil
} }

View File

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

View File

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

View File

@ -368,6 +368,7 @@ func ValidateFrameDuration(duration time.Duration) error {
cache := GetCachedConfig() cache := GetCachedConfig()
// Convert frameSize (samples) to duration for comparison // Convert frameSize (samples) to duration for comparison
// Note: This calculation should match how frameSize is converted to duration elsewhere
cachedFrameSize := int(cache.frameSize.Load()) cachedFrameSize := int(cache.frameSize.Load())
cachedSampleRate := int(cache.sampleRate.Load()) cachedSampleRate := int(cache.sampleRate.Load())
@ -475,6 +476,9 @@ func ValidateAudioConfigConstants(config *AudioConfigConstants) error {
return nil 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 // Global variable for backward compatibility
var cachedMaxFrameSize int var cachedMaxFrameSize int

View File

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

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",") return strings.Split(s, ",")
} }
func toString(v any) (string, error) { func toString(v interface{}) (string, error) {
switch v := v.(type) { switch v := v.(type) {
case string: case string:
return v, nil 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, TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"}, PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"}, FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value any, name string) string { FormatPartValueByName: func(value interface{}, name string) string {
val := fmt.Sprintf("%s", value) val := fmt.Sprintf("%s", value)
if name == "component" { if name == "component" {
if value == nil { if value == nil {
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
continue continue
} }
scopes := strings.SplitSeq(strings.ToLower(env), ",") scopes := strings.Split(strings.ToLower(env), ",")
for scope := range scopes { for _, scope := range scopes {
l.scopeLevels[scope] = level l.scopeLevels[scope] = level
} }
} }

View File

@ -13,32 +13,32 @@ type pionLogger struct {
func (c pionLogger) Trace(msg string) { func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg) 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...) c.logger.Trace().Msgf(format, args...)
} }
func (c pionLogger) Debug(msg string) { func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg) 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...) c.logger.Debug().Msgf(format, args...)
} }
func (c pionLogger) Info(msg string) { func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg) 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...) c.logger.Info().Msgf(format, args...)
} }
func (c pionLogger) Warn(msg string) { func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg) 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...) c.logger.Warn().Msgf(format, args...)
} }
func (c pionLogger) Error(msg string) { func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg) 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...) c.logger.Error().Msgf(format, args...)
} }

View File

@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
return &defaultLogger 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 // TODO: move rootLogger to logging package
if l == nil { if l == nil {
l = &defaultLogger 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) hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false 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") { if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true hostLineExists = true
line = hostLine line = hostLine

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
package usbgadget package usbgadget
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
"reflect"
"time" "time"
) )
@ -61,8 +61,6 @@ var keyboardReportDesc = []byte{
const ( const (
hidReadBufferSize = 8 hidReadBufferSize = 8
hidKeyBufferSize = 6
hidErrorRollOver = 0x01
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0 KeyboardLedMaskNumLock = 1 << 0
@ -70,9 +68,7 @@ const (
KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4 KeyboardLedMaskKana = 1 << 4
// power on/off LED is 5 ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
KeyboardLedMaskShift = 1 << 6
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift
) )
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK, // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
@ -85,13 +81,6 @@ type KeyboardState struct {
ScrollLock bool `json:"scroll_lock"` ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"` Compose bool `json:"compose"`
Kana bool `json:"kana"` 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 { func getKeyboardState(b byte) KeyboardState {
@ -102,28 +91,27 @@ func getKeyboardState(b byte) KeyboardState {
ScrollLock: b&KeyboardLedMaskScrollLock != 0, ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0, Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 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() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
if state&^ValidKeyboardLedMasks != 0 { if b&^ValidKeyboardLedMasks != 0 {
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
return return
} }
if u.keyboardState == state { newState := getKeyboardState(b)
if reflect.DeepEqual(u.keyboardState, newState) {
return return
} }
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
u.keyboardState = state u.keyboardState = newState
if u.onKeyboardStateChange != nil { if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(getKeyboardState(state)) (*u.onKeyboardStateChange)(newState)
} }
} }
@ -135,42 +123,7 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
return getKeyboardState(u.keyboardState) return 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
} }
func (u *UsbGadget) listenKeyboardEvents() { func (u *UsbGadget) listenKeyboardEvents() {
@ -189,7 +142,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
l.Info().Msg("context done") l.Info().Msg("context done")
return return
default: default:
l.Trace().Msg("reading from keyboard for LED state changes") l.Trace().Msg("reading from keyboard")
if u.keyboardHidFile == nil { if u.keyboardHidFile == nil {
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs // show the error every 100 times to avoid spamming the logs
@ -206,7 +159,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") 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 { if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got") l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue continue
@ -242,12 +195,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile() 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 { if err := u.openKeyboardHidFile(); err != nil {
return err return err
} }
_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) _, err := u.keyboardHidFile.Write(data)
if err != nil { if err != nil {
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
// Keep file open on write errors to reduce I/O overhead // 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 return nil
} }
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
// 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) {
u.keyboardLock.Lock() u.keyboardLock.Lock()
defer u.keyboardLock.Unlock() defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
if len(keys) > hidKeyBufferSize { if len(keys) > 6 {
keys = keys[:hidKeyBufferSize] keys = keys[:6]
} }
if len(keys) < hidKeyBufferSize { if len(keys) < 6 {
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) 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 { 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 u.resetUserInputTime()
} return nil
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
} }

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 { if err != nil {
u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1") u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
// Keep file open on write errors to reduce I/O overhead // Keep file open on write errors to reduce I/O overhead
@ -84,17 +84,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil 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() u.absMouseLock.Lock()
defer u.absMouseLock.Unlock() defer u.absMouseLock.Unlock()
err := u.absMouseWriteHidFile([]byte{ err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1 1, // Report ID 1
buttons, // Buttons buttons, // Buttons
byte(x), // X Low Byte uint8(x), // X Low Byte
byte(x >> 8), // X High Byte uint8(x >> 8), // X High Byte
byte(y), // Y Low Byte uint8(y), // Y Low Byte
byte(y >> 8), // Y High Byte uint8(y >> 8), // Y High Byte
}) })
if err != nil { if err != nil {
return err 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 { if err != nil {
u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2") u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
// Keep file open on write errors to reduce I/O overhead // Keep file open on write errors to reduce I/O overhead
@ -74,14 +74,14 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
return nil 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() u.relMouseLock.Lock()
defer u.relMouseLock.Unlock() defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{ err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons buttons, // Buttons
byte(mx), // X uint8(mx), // X
byte(my), // Y uint8(my), // Y
0, // Wheel 0, // Wheel
}) })
if err != nil { 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, MassStorage: true,
} }
type KeysDownState struct {
Modifier byte `json:"modifier"`
Keys ByteSlice `json:"keys"`
}
// UsbGadget is a struct that represents a USB gadget. // UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct { type UsbGadget struct {
name string name string
@ -66,9 +61,7 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex relMouseLock sync.Mutex
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) keyboardState KeyboardState
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
keyboardStateLock sync.Mutex keyboardStateLock sync.Mutex
keyboardStateCtx context.Context keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc keyboardStateCancel context.CancelFunc
@ -85,7 +78,6 @@ type UsbGadget struct {
txLock sync.Mutex txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState) onKeyboardStateChange *func(state KeyboardState)
onKeysDownChange *func(state KeysDownState)
log *zerolog.Logger log *zerolog.Logger
@ -191,8 +183,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
txLock: sync.Mutex{}, txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx, keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel, keyboardStateCancel: keyboardCancel,
keyboardState: 0, keyboardState: KeyboardState{},
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
enabledDevices: *enabledDevices, enabledDevices: *enabledDevices,
lastUserInput: time.Now(), lastUserInput: time.Now(),
log: logger, log: logger,

View File

@ -2,43 +2,14 @@ package usbgadget
import ( import (
"bytes" "bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/rs/zerolog" "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 { func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...) pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...) return filepath.Join(pathArr...)
@ -110,32 +81,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
return false return false
} }
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) { func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
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) {
u.logSuppressionLock.Lock() u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock() defer u.logSuppressionLock.Unlock()

View File

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

3
log.go
View File

@ -5,7 +5,7 @@ import (
"github.com/rs/zerolog" "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...) return logging.ErrorfL(l, format, err, args...)
} }
@ -19,7 +19,6 @@ var (
nbdLogger = logging.GetSubsystemLogger("nbd") nbdLogger = logging.GetSubsystemLogger("nbd")
timesyncLogger = logging.GetSubsystemLogger("timesync") timesyncLogger = logging.GetSubsystemLogger("timesync")
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc") jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")
hidRPCLogger = logging.GetSubsystemLogger("hidrpc")
watchdogLogger = logging.GetSubsystemLogger("watchdog") watchdogLogger = logging.GetSubsystemLogger("watchdog")
websecureLogger = logging.GetSubsystemLogger("websecure") websecureLogger = logging.GetSubsystemLogger("websecure")
otaLogger = logging.GetSubsystemLogger("ota") otaLogger = logging.GetSubsystemLogger("ota")

329
native.go
View File

@ -1,255 +1,46 @@
//go:build linux
package kvm package kvm
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"io"
"net"
"os" "os"
"os/exec" "os/exec"
"strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/jetkvm/kvm/resource" "github.com/rs/zerolog"
"github.com/pion/webrtc/v4/pkg/media"
) )
var ctrlSocketConn net.Conn type nativeOutput struct {
logger *zerolog.Logger
type CtrlAction struct {
Action string `json:"action"`
Seq int32 `json:"seq,omitempty"`
Params map[string]any `json:"params,omitempty"`
} }
type CtrlResponse struct { func (n *nativeOutput) Write(p []byte) (int, error) {
Seq int32 `json:"seq,omitempty"` n.logger.Debug().Str("output", string(p)).Msg("native binary output")
Error string `json:"error,omitempty"` return len(p), nil
Errno int32 `json:"errno,omitempty"`
Result map[string]any `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 ( var (
nativeCmd *exec.Cmd nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{} nativeCmdLock = &sync.Mutex{}
) )
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) { func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
lock.Lock() cmd := exec.Command(binaryPath)
defer lock.Unlock() cmd.SysProcAttr = &syscall.SysProcAttr{
ctrlAction := CtrlAction{ Pdeathsig: syscall.SIGTERM,
Action: action,
Seq: seq,
Params: params,
} }
cmd.Stdout = &nativeOutput{logger: nativeLogger}
cmd.Stderr = &nativeOutput{logger: nativeLogger}
responseChan := make(chan *CtrlResponse) err := cmd.Start()
ongoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
if err != nil { if err != nil {
delete(ongoingRequests, ctrlAction.Seq) return nil, err
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
} }
scopedLogger := nativeLogger.With(). return cmd, nil
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")
}
}
}
} }
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) { func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
@ -351,87 +142,3 @@ func ExtractAndRunNativeBin() error {
return nil 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) { 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"], groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always", "newlines-between": "always",
}], }],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
}, },
settings: { settings: {

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app --> <!-- These are the fonts used in the app -->
<link <link
@ -27,14 +27,7 @@
/> />
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" href="/favicon.png" />
<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." />
<script> <script>
// Initial theme setup // Initial theme setup
document.documentElement.classList.toggle( 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", "name": "kvm-ui",
"private": true, "private": true,
"version": "2025.09.03.2100", "version": "2025.08.07.001",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "22.15.0" "node": "22.15.0"
@ -30,7 +30,7 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.4", "cva": "^1.0.0-beta.4",
"dayjs": "^1.11.18", "dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
@ -39,35 +39,35 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router": "^7.8.2", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.119", "react-simple-keyboard": "^3.8.106",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^3.1.2", "recharts": "^2.15.3",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.15", "validator": "^13.15.15",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.2", "@eslint/compat": "^1.3.1",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.34.0", "@eslint/js": "^9.32.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.12", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.7",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.3", "@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.42.0", "@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.42.0", "@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^4.0.1", "@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.34.0", "eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
@ -77,9 +77,9 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.11",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.4", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "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; microphone: MicrophoneHookReturn;
}) { }) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
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( const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState, 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 // 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 // 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; isOpen.current = open;
if (!open) { if (!open) {
setTimeout(() => { setTimeout(() => {
setDisableVideoFocusTrap(false); setDisableFocusTrap(false);
console.debug("Popover is closing. Returning focus trap to video"); console.log("Popover is closing. Returning focus trap to video");
}, 0); }, 0);
} }
} }
}, },
[setDisableVideoFocusTrap], [setDisableFocusTrap],
); );
// Use WebSocket-based audio events for real-time updates // Use WebSocket-based audio events for real-time updates
@ -114,7 +118,7 @@ export default function Actionbar({
text="Paste text" text="Paste text"
LeadingIcon={MdOutlineContentPasteGo} LeadingIcon={MdOutlineContentPasteGo}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -156,7 +160,7 @@ export default function Actionbar({
); );
}} }}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -187,7 +191,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Wake on LAN" text="Wake on LAN"
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<svg <svg
@ -237,7 +241,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/> />
</div> </div>
</div> </div>
@ -251,7 +255,7 @@ export default function Actionbar({
text="Extension" text="Extension"
LeadingIcon={LuCable} LeadingIcon={LuCable}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -276,7 +280,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/> />
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
@ -302,10 +306,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => { onClick={() => navigateTo("/settings")}
setDisableVideoFocusTrap(true);
navigateTo("/settings")
}}
/> />
</div> </div>
@ -341,7 +342,7 @@ export default function Actionbar({
)} )}
onClick={() => { onClick={() => {
if (isAudioEnabledInUsb) { 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 { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons"; import { GoogleIcon } from "@components/Icons";

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
@ -48,7 +48,7 @@ export default function DashboardNavbar({
navigate("/"); navigate("/");
}, [navigate, setUser]); }, [navigate, setUser]);
const { usbState } = useHidStore(); const usbState = useHidStore(state => state.usbState);
// for testing // for testing
//userEmail = "user@example.org"; //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 { cx } from "@/cva.config";
import { import {
@ -7,70 +7,64 @@ import {
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
VideoState
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { useHidRpc } from "@/hooks/useHidRpc";
export default function InfoBar() { export default function InfoBar() {
const { keysDownState } = useHidStore(); const activeKeys = useHidStore(state => state.activeKeys);
const { mouseX, mouseY, mouseMove } = useMouseStore(); const activeModifiers = useHidStore(state => state.activeModifiers);
const { rpcHidStatus } = useHidRpc(); const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( 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( 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 rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); rpcDataChannel.onclose = () => { /* RPC data channel closed */ };
rpcDataChannel.onerror = (e: Event) => rpcDataChannel.onerror = () => { /* Error on RPC data channel */ };
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const { keyboardLedState, usbState } = useHidStore(); const keyboardLedState = useHidStore(state => state.keyboardLedState);
const { isTurnServerInUse } = useRTCStore(); const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const { hdmiState } = useVideoStore(); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const displayKeys = useMemo(() => { const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
if (!showPressedKeys)
return "";
const activeModifierMask = keysDownState.modifier || 0; const usbState = useHidStore(state => state.usbState);
const keysDown = keysDownState.keys || []; const hdmiState = useVideoStore(state => state.hdmiState);
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]);
return ( 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="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 flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4"> <div className="flex flex-wrap items-center pl-2 gap-x-4">
{debugMode ? ( {settings.debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "} <span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span> <span className="text-xs">{videoSize}</span>
</div> </div>
) : null} ) : null}
{debugMode ? ( {settings.debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Video Size: </span> <span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span> <span className="text-xs">{videoClientSize}</span>
</div> </div>
) : null} ) : null}
{(debugMode && mouseMode == "absolute") ? ( {(settings.debugMode && settings.mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span> <span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs"> <span className="text-xs">
@ -79,7 +73,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(debugMode && mouseMode == "relative") ? ( {(settings.debugMode && settings.mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span> <span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs"> <span className="text-xs">
@ -90,30 +84,31 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{debugMode && ( {settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span> <span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span> <span className="text-xs">{usbState}</span>
</div> </div>
)} )}
{debugMode && ( {settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span> <span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span> <span className="text-xs">{hdmiState}</span>
</div> </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 && ( {showPressedKeys && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs"> <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> </h2>
</div> </div>
)} )}
@ -126,10 +121,23 @@ export default function InfoBar() {
</div> </div>
)} )}
{keyboardLedStateSyncAvailable ? (
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "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-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -139,7 +147,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState.num_lock keyboardLedState?.num_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -149,28 +157,23 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState.scroll_lock keyboardLedState?.scroll_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
> >
Scroll Lock Scroll Lock
</div> </div>
{keyboardLedState.compose ? ( {keyboardLedState?.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Compose Compose
</div> </div>
) : null} ) : null}
{keyboardLedState.kana ? ( {keyboardLedState?.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Kana Kana
</div> </div>
) : null} ) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import { MdConnectWithoutContact } from "react-icons/md"; import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; 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 { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
@ -65,22 +65,21 @@ function Terminal({
readonly dataChannel: RTCDataChannel; readonly dataChannel: RTCDataChannel;
readonly type: AvailableTerminalTypes; readonly type: AvailableTerminalTypes;
}) { }) {
const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const enableTerminal = useUiStore(state => state.terminalType == type);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const isTerminalTypeEnabled = useMemo(() => { const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
return terminalType == type;
}, [terminalType, type]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setDisableVideoFocusTrap(isTerminalTypeEnabled); setDisableVideoFocusTrap(enableTerminal);
}, 500); }, 500);
return () => { return () => {
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]); }, [enableTerminal, setDisableVideoFocusTrap]);
const readyState = dataChannel.readyState; const readyState = dataChannel.readyState;
useEffect(() => { useEffect(() => {
@ -176,9 +175,9 @@ function Terminal({
], ],
{ {
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300": "pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!isTerminalTypeEnabled, !enableTerminal,
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300": "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 KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import { USBStates } from "@/hooks/stores"; import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = Record< type StatusProps = Record<
USBStates, USBStates,
@ -65,7 +67,7 @@ export default function USBStateStatus({
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.warn("Unsupported USB state: ", state); console.log("Unsupported USB state: ", state);
return; return;
} }

View File

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

View File

@ -101,8 +101,8 @@ export function UsbInfoSetting() {
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`, `Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
); );
} else { } else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState; const usbConfigState = resp.result as UsbConfigState;
console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState);
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product ? usbConfigState.product
: "custom"; : "custom";
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
); );
useEffect(() => { useEffect(() => {
send("getDeviceID", {}, (resp: JsonRpcResponse) => { send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown 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 { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard"; import Keyboard from "react-simple-keyboard";
import { LuKeyboard } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
// eslint-disable-next-line import/order // 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 "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
import { decodeModifiers, keys, latchingKeys, modifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => { export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />; 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() { function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null); const keyboardRef = useRef<HTMLDivElement>(null);
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = const showAttachedVirtualKeyboard = useUiStore(
useUiStore(); state => state.isAttachedVirtualKeyboardVisible,
const { keyboardLedState, keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = );
useHidStore(); const setShowAttachedVirtualKeyboard = useUiStore(
const { handleKeyPress, executeMacro } = useKeyboard(); state => state.setAttachedVirtualKeyboardVisibility,
const { selectedKeyboard } = useKeyboardLayout(); );
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const keyDisplayMap = useMemo(() => { const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
return selectedKeyboard.keyDisplayMap;
}, [selectedKeyboard]);
const virtualKeyboard = useMemo(() => { // HID related states
return selectedKeyboard.virtualKeyboard; const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
}, [selectedKeyboard]); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const { isShiftActive } = useMemo(() => { const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
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 startDrag = useCallback((e: MouseEvent | TouchEvent) => { const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return; if (!keyboardRef.current) return;
@ -113,9 +97,6 @@ function KeyboardWrapper() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// Is the keyboard detached or attached?
if (isAttachedVirtualKeyboardVisible) return;
const handle = keyboardRef.current; const handle = keyboardRef.current;
if (handle) { if (handle) {
handle.addEventListener("touchstart", startDrag); handle.addEventListener("touchstart", startDrag);
@ -140,76 +121,96 @@ function KeyboardWrapper() {
document.removeEventListener("mousemove", onDrag); document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag); document.removeEventListener("touchmove", onDrag);
}; };
}, [isAttachedVirtualKeyboardVisible, endDrag, onDrag, startDrag]); }, [endDrag, onDrag, startDrag]);
const onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
}, []);
const onKeyDown = useCallback( const onKeyDown = useCallback(
async (key: string, e: MouseEvent | undefined) => { (key: string) => {
e?.preventDefault(); const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
e?.stopPropagation(); 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") { if (key === "CtrlAltDelete") {
await executeMacro([ sendKeyboardEvent(
{ keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, [keys["Delete"]],
]); [modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "AltMetaEscape") { if (key === "AltMetaEscape") {
await executeMacro([ sendKeyboardEvent(
{ keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 }, [keys["Escape"]],
]); [modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "CtrlAltBackspace") { if (key === "CtrlAltBackspace") {
await executeMacro([ sendKeyboardEvent(
{ keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, [keys["Backspace"]],
]); [modifiers["ControlLeft"], modifiers["AltLeft"]],
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`,
); );
handleKeyPress(keys[key], !currentlyDown);
setTimeout(resetKeyboardState, 100);
return; return;
} }
// otherwise, just treat it as a down+up pair if (isKeyShift || isKeyCaps) {
const cleanKey = key.replace(/[()]/g, ""); toggleLayout();
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
handleKeyPress(keys[cleanKey], true); if (isCapsLockActive) {
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50); 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 ( return (
<div <div
className="transition-all duration-500 ease-in-out" className="transition-all duration-500 ease-in-out"
style={{ style={{
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`, marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}} }}
> >
<AnimatePresence> <AnimatePresence>
{isVirtualKeyboardEnabled && ( {virtualKeyboard && (
<motion.div <motion.div
initial={{ opacity: 0, y: "100%" }} initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }} animate={{ opacity: 1, y: "0%" }}
@ -221,62 +222,51 @@ function KeyboardWrapper() {
> >
<div <div
className={cx( className={cx(
!isAttachedVirtualKeyboardVisible !showAttachedVirtualKeyboard
? "fixed top-0 left-0 z-10 select-none" ? "fixed left-0 top-0 z-50 select-none"
: "relative", : "relative",
)} )}
ref={keyboardRef} ref={keyboardRef}
style={{ style={{
...(!isAttachedVirtualKeyboardVisible ...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` } ? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}), : {}),
}} }}
> >
<Card <Card
className={cx("overflow-hidden", { className={cx("overflow-hidden", {
"rounded-none": isAttachedVirtualKeyboardVisible, "rounded-none": showAttachedVirtualKeyboard,
"keyboard-detached": !isAttachedVirtualKeyboardVisible,
})} })}
> >
<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"> <div className="absolute left-2 flex items-center gap-x-2">
{isAttachedVirtualKeyboardVisible ? ( {showAttachedVirtualKeyboard ? (
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Detach" text="Detach"
onClick={() => setAttachedVirtualKeyboardVisibility(false)} onClick={() => setShowAttachedVirtualKeyboard(false)}
/> />
) : ( ) : (
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Attach" text="Attach"
onClick={() => setAttachedVirtualKeyboardVisibility(true)} LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/> />
)} )}
</div> </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 Virtual Keyboard
</h2> </h2>
<div className="absolute right-2 flex items-center gap-x-2"> <div className="absolute right-2">
<div className="hidden md:flex gap-x-2 items-center">
<LinkButton
size="XS"
to="settings/keyboard"
theme="light"
text={selectedKeyboard.name}
LeadingIcon={LuKeyboard}
/>
<div className="h-[20px] w-px bg-slate-800/20 dark:bg-slate-200/20" />
</div>
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Hide" text="Hide"
LeadingIcon={ChevronDownIcon} LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboardEnabled(false)} onClick={() => setVirtualKeyboard(false)}
/> />
</div> </div>
</div> </div>
@ -285,61 +275,66 @@ function KeyboardWrapper() {
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700"> <div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard <Keyboard
baseClass="simple-keyboard-main" baseClass="simple-keyboard-main"
layoutName={mainLayoutName} layoutName={layoutName}
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
buttonTheme={[ buttonTheme={[
{ {
class: "combination-key", class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace", buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
}, },
{
class: "down-key",
buttons: keyNamesForDownKeys.join(" "),
},
]} ]}
display={keyDisplayMap} 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} disableButtonHold={true}
enableLayoutCandidates={false} syncInstanceInputs={true}
preventMouseDownDefault={true} debug={false}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
<div className="controlArrows"> <div className="controlArrows">
<Keyboard <Keyboard
baseClass="simple-keyboard-control" baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName="default" layoutName={layoutName}
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap} display={keyDisplayMap}
layout={virtualKeyboard.control} layout={{
disableButtonHold={true} default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
enableLayoutCandidates={false} shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
preventMouseDownDefault={true} }}
preventMouseUpDefault={true} syncInstanceInputs={true}
stopMouseDownPropagation={true} debug={false}
stopMouseUpPropagation={true}
/> />
<Keyboard <Keyboard
baseClass="simple-keyboard-arrows" baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap} display={keyDisplayMap}
layout={virtualKeyboard.arrows} layout={{
disableButtonHold={true} default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
enableLayoutCandidates={false} }}
preventMouseDownDefault={true} syncInstanceInputs={true}
preventMouseUpDefault={true} debug={false}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
</div> </div>
{/* TODO add optional number pad */}
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -7,14 +7,16 @@ import MacroBar from "@/components/MacroBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import notifications from "@/notifications"; import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { import {
useHidStore,
useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import useMouse from "@/hooks/useMouse";
import { import {
HDMIErrorOverlay, HDMIErrorOverlay,
@ -51,22 +53,15 @@ interface WebRTCVideoProps {
export default function WebRTCVideo({ microphone }: WebRTCVideoProps) { export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// Video and stream related refs and states // Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
const { mediaStream, peerConnectionState } = useRTCStore(); const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();
const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const { const setMousePosition = useMouseStore(state => state.setMousePosition);
getRelMouseMoveHandler, const setMouseMove = useMouseStore(state => state.setMouseMove);
getAbsMouseMoveHandler,
getMouseWheelHandler,
resetMousePosition,
} = useMouse();
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -74,33 +69,49 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
height: videoHeight, height: videoHeight,
clientWidth: videoClientWidth, clientWidth: videoClientWidth,
clientHeight: videoClientHeight, clientHeight: videoClientHeight,
hdmiState,
} = useVideoStore(); } = useVideoStore();
// Video enhancement settings // 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 // RTC related states
const { peerConnection } = useRTCStore(); const peerConnection = useRTCStore(state => state.peerConnection);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
// Video-related const [blockWheelEvent, setBlockWheelEvent] = useState(false);
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]
);
// Misc states and hooks
const { send } = useJsonRpc();
// Video-related
useResizeObserver({ useResizeObserver({
ref: videoElm as React.RefObject<HTMLElement>, 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( const updateVideoSizeStore = useCallback(
@ -121,14 +132,15 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, },
[updateVideoSizeStore], [setVideoClientSize, updateVideoSizeStore, setVideoSize],
); );
// Pointer lock and keyboard lock related // Pointer lock and keyboard lock related
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isFullscreenEnabled = document.fullscreenEnabled; const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => { 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 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"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted && navigator && "keyboard" in navigator) { if (isKeyboardLockGranted && "keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard lock is not supported in all browsers // @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock(); await navigator.keyboard.lock();
setIsKeyboardLockActive(true);
} catch { } catch {
// ignore errors // ignore errors
} }
} }
}, [checkNavigatorPermissions, setIsKeyboardLockActive]); }, [checkNavigatorPermissions]);
const releaseKeyboardLock = useCallback(async () => { const releaseKeyboardLock = useCallback(async () => {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if (navigator && "keyboard" in navigator) { if ("keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard unlock is not supported in all browsers // @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock(); await navigator.keyboard.unlock();
} catch { } catch {
// ignore errors // ignore errors
} }
setIsKeyboardLockActive(false);
} }
}, [setIsKeyboardLockActive]); }, []);
useEffect(() => { useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return; if (!isPointerLockPossible || !videoElm.current) return;
@ -236,81 +246,276 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
} }
}; };
document.addEventListener("fullscreenchange", handleFullscreenChange); document.addEventListener("fullscreenchange ", handleFullscreenChange);
}, [releaseKeyboardLock]); }, [releaseKeyboardLock]);
const absMouseMoveHandler = useMemo( // Mouse-related
() => getAbsMouseMoveHandler({ const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
videoClientWidth,
videoClientHeight, const sendRelMouseMovement = useCallback(
videoWidth, (x: number, y: number, buttons: number) => {
videoHeight, if (settings.mouseMode !== "relative") return;
}), // if we ignore the event, double-click will not work
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight], // 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( const relMouseMoveHandler = useCallback(
() => getRelMouseMoveHandler(), (e: MouseEvent) => {
[getRelMouseMoveHandler], 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( const sendAbsMouseMovement = useCallback(
() => getMouseWheelHandler(), (x: number, y: number, buttons: number) => {
[getMouseWheelHandler], 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( const keyDownHandler = useCallback(
(e: KeyboardEvent) => { async (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const code = getAdjustedKeyCode(e); const prev = useHidStore.getState();
const hidKey = keys[code]; let code = e.code;
const key = e.key;
if (hidKey === undefined) { if (!isKeyboardLedManagedByHost) {
console.warn(`Key down not mapped: ${code}`); setIsNumLockActive(e.getModifierState("NumLock"));
return; 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 // 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 // event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey && hidKey < 0xE0) { if (e.metaKey) {
setTimeout(() => { setTimeout(() => {
console.debug(`Forcing the meta key release of associated key: ${hidKey}`); const prev = useHidStore.getState();
handleKeyPress(hidKey, false); sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10); }, 10);
} }
console.debug(`Key down: ${hidKey}`);
handleKeyPress(hidKey, true);
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
// 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);
}
}, },
[handleKeyPress, isKeyboardLockActive], [
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
); );
const keyUpHandler = useCallback( const keyUpHandler = useCallback(
async (e: KeyboardEvent) => { (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const code = getAdjustedKeyCode(e); const prev = useHidStore.getState();
const hidKey = keys[code];
if (hidKey === undefined) { if (!isKeyboardLedManagedByHost) {
console.warn(`Key up not mapped: ${code}`); setIsNumLockActive(e.getModifierState("NumLock"));
return; setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
console.debug(`Key up: ${hidKey}`); // Filtering out the key that was just released (keys[e.code])
handleKeyPress(hidKey, false); 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) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -321,7 +526,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// Fix only works in chrome based browsers. // Fix only works in chrome based browsers.
if (e.code === "Space") { if (e.code === "Space") {
if (videoElm.current.paused) { if (videoElm.current.paused) {
console.debug("Force playing video"); console.log("Force playing video");
videoElm.current.play(); videoElm.current.play();
} }
} }
@ -364,7 +569,13 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
// We set the as early as possible // We set the as early as possible
addStreamToVideoElm(mediaStream); addStreamToVideoElm(mediaStream);
}, },
[addStreamToVideoElm, mediaStream], [
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
); );
// Setup Keyboard Events // Setup Keyboard Events
@ -413,16 +624,14 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
function setMouseModeEventListeners() { function setMouseModeEventListeners() {
const videoElmRefValue = videoElm.current; const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return; if (!videoElmRefValue) return;
const isRelativeMouseMode = (settings.mouseMode === "relative"); const isRelativeMouseMode = (settings.mouseMode === "relative");
const mouseHandler = isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler;
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseHandler, { signal }); videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseHandler, { signal }); videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, signal,
passive: true, passive: true,
@ -450,16 +659,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
abortController.abort(); abortController.abort();
}; };
}, },
[ [absMouseMoveHandler, isPointerLockActive, isPointerLockPossible, mouseWheelHandler, relMouseMoveHandler, requestPointerLock, resetMousePosition, settings.mouseMode],
isPointerLockActive,
isPointerLockPossible,
requestPointerLock,
absMouseMoveHandler,
relMouseMoveHandler,
mouseWheelHandler,
resetMousePosition,
settings.mouseMode,
],
); );
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -492,18 +692,6 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
}; };
}, [videoSaturation, videoBrightness, videoContrast]); }, [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 ( return (
<div className="grid h-full w-full grid-rows-(--grid-layout)"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">

View File

@ -49,7 +49,7 @@ export function SerialConsole() {
setSettings(newSettings); setSettings(newSettings);
}); });
}; };
const { setTerminalType } = useUiStore(); const setTerminalType = useUiStore(state => state.setTerminalType);
return ( return (
<div className="space-y-4"> <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 { PlusCircleIcon } from "@heroicons/react/20/solid";
import { forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink, LuLink,
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
} from "react-icons/lu"; } from "react-icons/lu";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router"; import { useLocation } from "react-router-dom";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications"; import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore(); 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(() => { const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => { send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) { if ("error" in response) {
@ -70,6 +94,42 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { source, filename, size, url, path } = remoteVirtualMediaState; const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) { 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": case "HTTP":
return ( return (
<div className=""> <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." 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 <div
className="animate-fadeIn opacity-0 space-y-2" className="animate-fadeIn opacity-0 space-y-2"
style={{ 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 { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
@ -10,8 +10,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { KeyStroke } from "@/keyboardLayouts"; import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications"; import notifications from "@/notifications";
const hidKeyboardPayload = (modifier: number, keys: number[]) => { const hidKeyboardPayload = (modifier: number, keys: number[]) => {
@ -19,24 +18,33 @@ const hidKeyboardPayload = (modifier: number, keys: number[]) => {
}; };
const modifierCode = (shift?: boolean, altRight?: boolean) => { const modifierCode = (shift?: boolean, altRight?: boolean) => {
return (shift ? modifiers.ShiftLeft : 0) return (shift ? modifiers["ShiftLeft"] : 0)
| (altRight ? modifiers.AltRight : 0) | (altRight ? modifiers["AltRight"] : 0)
} }
const noModifier = 0 const noModifier = 0
export default function PasteModal() { export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null); const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const { setPasteModeEnabled } = useHidStore(); const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const { setDisableVideoFocusTrap } = useUiStore(); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose(); const close = useClose();
const { setKeyboardLayout } = useSettingsStore(); const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const { selectedKeyboard } = useKeyboardLayout(); 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(() => { useEffect(() => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
@ -46,23 +54,24 @@ export default function PasteModal() {
}, [send, setKeyboardLayout]); }, [send, setKeyboardLayout]);
const onCancelPasteMode = useCallback(() => { const onCancelPasteMode = useCallback(() => {
setPasteModeEnabled(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
setInvalidChars([]); setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteModeEnabled]); }, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => { const onConfirmPaste = useCallback(async () => {
setPasteModeEnabled(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!selectedKeyboard) return; const keyboard: KeyboardLayout = selectedKeyboard(safeKeyboardLayout);
if (!keyboard) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
for (const char of text) { for (const char of text) {
const keyprops = selectedKeyboard.chars[char]; const keyprops = keyboard.chars[char];
if (!keyprops) continue; if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops; 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(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -152,7 +161,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)] [...new Intl.Segmenter().segment(value)]
.map(x => x.segment) .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>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400"> <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> </p>
</div> </div>
</div> </div>

View File

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

View File

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

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