Compare commits
21 Commits
fcd07b2b59
...
8a189ba1b9
| Author | SHA1 | Date |
|---|---|---|
|
|
8a189ba1b9 | |
|
|
d3bbe1bf0a | |
|
|
158437352c | |
|
|
e45bec4a9c | |
|
|
2c2f2d416b | |
|
|
0a38451c95 | |
|
|
d9072673c0 | |
|
|
9cb976ab8d | |
|
|
bcc307b147 | |
|
|
e8ef82e582 | |
|
|
5f3dd89d55 | |
|
|
1dda6184da | |
|
|
825d0311d6 | |
|
|
f3fe78af5d | |
|
|
d0b3781aaa | |
|
|
c68e15bf89 | |
|
|
94521ef6db | |
|
|
66cccfe9e1 | |
|
|
a42384fed6 | |
|
|
3ec243255b | |
|
|
05bf61152b |
|
|
@ -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@v4
|
uses: actions/checkout@v5
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # 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:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -22,25 +22,12 @@ 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()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
_, 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:
|
||||||
|
|
|
||||||
70
cloud.go
|
|
@ -39,8 +39,7 @@ 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
|
||||||
// Increased to 30 seconds for constrained environments to reduce overhead
|
WebsocketPingInterval = 15 * time.Second
|
||||||
WebsocketPingInterval = 30 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -77,6 +76,23 @@ 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",
|
||||||
|
|
@ -84,6 +100,13 @@ 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",
|
||||||
|
|
@ -424,17 +447,7 @@ func handleSessionRequest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var session *Session
|
session, err := newSession(SessionConfig{
|
||||||
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,
|
||||||
|
|
@ -446,48 +459,23 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
display.go
|
|
@ -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]interface{}{"obj": screen})
|
_, err := CallCtrlAction("lv_scr_load", map[string]any{"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]interface{}{"obj": objName, "state": state})
|
return CallCtrlAction("lv_obj_set_state", map[string]any{"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]interface{}{"obj": objName, "flag": flag})
|
return CallCtrlAction("lv_obj_add_flag", map[string]any{"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]interface{}{"obj": objName, "flag": flag})
|
return CallCtrlAction("lv_obj_clear_flag", map[string]any{"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]interface{}{"obj": objName, "opa": opacity})
|
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"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]interface{}{"obj": objName, "time": duration})
|
return CallCtrlAction("lv_obj_fade_in", map[string]any{"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]interface{}{"obj": objName, "time": duration})
|
return CallCtrlAction("lv_obj_fade_out", map[string]any{"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]interface{}{"obj": objName, "text": text})
|
return CallCtrlAction("lv_label_set_text", map[string]any{"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]interface{}{"obj": objName, "src": src})
|
return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
|
return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation})
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateLabelIfChanged(objName string, newText string) {
|
func updateLabelIfChanged(objName string, newText string) {
|
||||||
|
|
|
||||||
114
fuse.go
|
|
@ -1,114 +0,0 @@
|
||||||
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
|
|
@ -6,32 +6,31 @@ 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.14.1
|
github.com/coreos/go-oidc/v3 v3.15.0
|
||||||
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.3
|
github.com/go-co-op/gocron/v2 v2.16.5
|
||||||
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-20250601184604-370a9a75f341
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
|
||||||
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.3
|
github.com/pion/webrtc/v4 v4.1.4
|
||||||
github.com/pojntfx/go-nbd v0.3.2
|
github.com/pojntfx/go-nbd v0.3.2
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.23.0
|
||||||
github.com/prometheus/common v0.65.0
|
github.com/prometheus/common v0.66.0
|
||||||
github.com/prometheus/procfs v0.16.1
|
github.com/prometheus/procfs v0.17.0
|
||||||
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.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
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.40.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.41.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sys v0.34.0
|
golang.org/x/sys v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||||
|
|
@ -51,6 +50,7 @@ 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.6 // indirect
|
github.com/pion/dtls/v3 v3.0.7 // 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.20 // indirect
|
github.com/pion/rtp v1.8.22 // indirect
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.14 // indirect
|
github.com/pion/sdp/v3 v3.0.16 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
github.com/pion/srtp/v3 v3.0.7 // 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.0.2 // indirect
|
github.com/pion/turn/v4 v4.1.1 // 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,7 +85,8 @@ 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.27.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
80
go.sum
|
|
@ -18,8 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/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.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
|
||||||
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.0 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-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoNJH4uUmsQ86+0/QqpwEns0NyNLwKv0=
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/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,8 +92,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.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=
|
||||||
|
|
@ -107,8 +105,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.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
github.com/pion/ice/v4 v4.0.10 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=
|
||||||
|
|
@ -121,33 +119,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.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
|
github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc=
|
||||||
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
github.com/pion/sctp v1.8.39 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.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
|
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||||
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs=
|
||||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0=
|
||||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
github.com/pion/stun/v3 v3.0.0 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.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
|
||||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M=
|
||||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 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.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
github.com/psanford/httpreadat v0.1.0 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=
|
||||||
|
|
@ -167,8 +165,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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 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=
|
||||||
|
|
@ -185,10 +183,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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 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=
|
||||||
|
|
@ -196,15 +194,17 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 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=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -112,7 +112,8 @@ func handleKeyboardReportDirect(params map[string]interface{}) (interface{}, err
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, rpcKeyboardReport(modifier, keys)
|
_, err = rpcKeyboardReport(modifier, keys)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct handler for absolute mouse reports
|
// Direct handler for absolute mouse reports
|
||||||
|
|
|
||||||
|
|
@ -941,8 +941,6 @@ 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]))
|
||||||
|
|
@ -1084,72 +1082,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -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: input_ipc.go for microphone audio capture and processing
|
// Used in: ipc_input.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: input_ipc.go for microphone capture configuration
|
// Used in: ipc_input.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: input_ipc.go for microphone channel configuration
|
// Used in: ipc_input.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: input_ipc.go for microphone frame processing
|
// Used in: ipc_input.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: process_monitor.go for configuring thread scheduling behavior
|
// Used in: monitor_process.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: supervisor.go for managing audio process restarts and recovery
|
// Used in: output_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: supervisor.go for limiting restart attempts to prevent infinite loops
|
// Used in: output_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: supervisor.go for restart attempt rate limiting
|
// Used in: output_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: supervisor.go for implementing restart backoff strategy
|
// Used in: output_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: supervisor.go for capping exponential backoff delays
|
// Used in: output_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: buffer_pool.go for initial memory pool allocation
|
// Used in: util_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: buffer_pool.go for limiting pool growth
|
// Used in: util_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: buffer_pool.go for audio frame allocation
|
// Used in: util_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: buffer_pool.go for memory-aligned allocations
|
// Used in: util_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: goroutine_monitor.go for periodic goroutine count checks.
|
// Used in: monitor_goroutine.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.go for configuring audio process communication
|
// Used in: ipc_output.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.go for message header validation and protocol compliance
|
// Used in: ipc_output.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.go for message size validation and buffer allocation
|
// Used in: ipc_output.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.go for preventing blocking on slow IPC operations
|
// Used in: ipc_output.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.go for IPC quality monitoring
|
// Used in: ipc_output.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.go for message parsing and buffer allocation
|
// Used in: ipc_output.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: metrics.go, latency_monitor.go for performance tracking
|
// Used in: core_metrics.go, monitor_latency.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: metrics.go for periodic metrics updates
|
// Used in: core_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: metrics.go for smoothing performance metrics
|
// Used in: core_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: metrics.go for avoiding inaccurate initial measurements
|
// Used in: core_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: metrics.go for metrics data collection pipelines
|
// Used in: core_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: latency_monitor.go for latency trend analysis
|
// Used in: monitor_latency.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: process_monitor.go for monitoring CPU, memory, and system resources
|
// Used in: monitor_process.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: process_monitor.go for CPU usage validation
|
// Used in: monitor_process.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: process_monitor.go for CPU time calculations on embedded systems
|
// Used in: monitor_process.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: process_monitor.go for memory percentage calculations
|
// Used in: monitor_process.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: process_monitor.go for initial measurement stabilization
|
// Used in: monitor_process.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: process_monitor.go for CPU measurement stabilization
|
// Used in: monitor_process.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: process_monitor.go for controlling monitoring log frequency
|
// Used in: monitor_process.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: process_monitor.go for system clock validation
|
// Used in: monitor_process.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: adaptive_optimizer.go, quality_manager.go for performance optimization
|
// Used in: monitor_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: adaptive_optimizer.go for triggering quality improvements
|
// Used in: monitor_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: adaptive_optimizer.go for maintaining current quality
|
// Used in: monitor_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: adaptive_optimizer.go for triggering quality reductions
|
// Used in: monitor_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: adaptive_optimizer.go for memory-based quality decisions
|
// Used in: monitor_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: adaptive_optimizer.go for balanced memory management
|
// Used in: monitor_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: adaptive_optimizer.go for aggressive memory conservation
|
// Used in: monitor_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: adaptive_optimizer.go for latency-based quality decisions
|
// Used in: monitor_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: adaptive_optimizer.go for preventing excessive audio delay
|
// Used in: monitor_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: adaptive_optimizer.go for balancing CPU impact in optimization decisions
|
// Used in: monitor_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: adaptive_optimizer.go for balancing memory impact in optimization decisions
|
// Used in: monitor_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: adaptive_optimizer.go for balancing latency impact in optimization decisions
|
// Used in: monitor_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: adaptive_optimizer.go for setting optimization goals
|
// Used in: monitor_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: buffer_pool.go and adaptive_buffer.go for buffer management
|
// Used in: util_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: metrics.go for performance statistics updates
|
// Used in: core_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: supervisor.go for process monitoring and control
|
// Used in: output_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: supervisor.go for output process monitoring
|
// Used in: output_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: adaptive_optimizer.go for optimization stability control
|
// Used in: monitor_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: latency_monitor.go for latency optimization goals and threshold monitoring
|
// Used in: monitor_latency.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: adaptive_optimizer.go for system performance optimization
|
// Used in: monitor_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: adaptive_optimizer.go for preventing optimization oscillation
|
// Used in: monitor_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: adaptive_optimizer.go for detecting failed optimizations
|
// Used in: monitor_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: adaptive_optimizer.go for optimization target setting
|
// Used in: monitor_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: latency_monitor.go for latency tracking and alerting
|
// Used in: monitor_latency.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: latency_monitor.go for latency violation detection
|
// Used in: monitor_latency.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: latency_monitor.go for jitter detection and monitoring
|
// Used in: monitor_latency.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: latency_monitor.go for optimization timing control
|
// Used in: monitor_latency.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: latency_monitor.go for adaptive optimization decisions
|
// Used in: monitor_latency.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: buffer_pool.go for memory pool management
|
// Used in: util_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: buffer_pool.go for initial memory pool sizing
|
// Used in: util_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: buffer_pool.go for input-specific memory pool sizing
|
// Used in: util_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: metrics.go and various monitoring components
|
// Used in: core_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: metrics.go for exponential moving average calculations
|
// Used in: core_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.go, input_ipc.go for message protocol validation
|
// Used in: ipc_input.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: input_ipc.go for input message validation
|
// Used in: ipc_input.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.go for output message validation
|
// Used in: ipc_output.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: metrics.go, process_monitor.go for percentage conversions
|
// Used in: core_metrics.go, monitor_process.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: metrics.go for exponential moving averages
|
// Used in: core_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: adaptive_optimizer.go for balancing CPU vs memory considerations
|
// Used in: monitor_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: adaptive_optimizer.go for memory impact weighting
|
// Used in: monitor_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: adaptive_optimizer.go for latency impact weighting
|
// Used in: monitor_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: buffer_pool.go for pool expansion calculations
|
// Used in: util_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: latency_monitor.go for latency scaling operations
|
// Used in: monitor_latency.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: adaptive_optimizer.go for optimization behavior control
|
// Used in: monitor_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: process_monitor.go for system resource tracking
|
// Used in: monitor_process.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: process_monitor.go when system memory cannot be detected
|
// Used in: monitor_process.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: process_monitor.go for memory unit conversions
|
// Used in: monitor_process.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: process_monitor.go for CPU time calculations on ARM systems
|
// Used in: monitor_process.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: process_monitor.go when system clock cannot be detected
|
// Used in: monitor_process.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: process_monitor.go for legacy system compatibility
|
// Used in: monitor_process.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.go for IPC buffer management
|
// Used in: ipc_output.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.go for initial IPC buffer allocation
|
// Used in: ipc_output.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: input_ipc.go for microphone input communication
|
// Used in: ipc_input.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.go for audio output communication
|
// Used in: ipc_output.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: input_ipc.go and related input processing components
|
// Used in: ipc_input.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.go and related output processing components
|
// Used in: ipc_output.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: supervisor.go and server management components
|
// Used in: output_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: validation.go for parameter validation
|
// Used in: core_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: validation.go for latency validation
|
// Used in: core_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: validation.go for metrics interval validation
|
// Used in: core_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: validation.go for sample rate validation
|
// Used in: core_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: validation.go for channel count validation
|
// Used in: core_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: adaptive_optimizer.go, quality_manager.go for performance scaling
|
// Used in: monitor_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: adaptive_optimizer.go for weighted performance scoring
|
// Used in: monitor_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,
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -368,7 +368,6 @@ 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())
|
||||||
|
|
||||||
|
|
@ -476,9 +475,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -8,6 +8,11 @@ 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 {
|
||||||
|
|
@ -25,7 +25,6 @@ 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()
|
||||||
|
|
@ -46,7 +45,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.Debug().Msg("CGO audio playback initialized successfully")
|
logger.Info().Msg("CGO audio playback initialized successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and start the IPC server
|
// Create and start the IPC server
|
||||||
|
|
@ -63,7 +62,7 @@ func RunAudioInputServer() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug().Msg("audio input server started, waiting for connections")
|
logger.Info().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())
|
||||||
|
|
@ -77,16 +76,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ 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
|
||||||
|
|
@ -117,14 +117,12 @@ 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
|
||||||
|
|
@ -267,13 +265,11 @@ 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 {
|
||||||
|
|
@ -290,8 +286,6 @@ 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ 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
|
||||||
|
|
@ -8,6 +8,11 @@ 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
|
||||||
|
|
@ -208,8 +213,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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()
|
||||||
|
|
@ -51,7 +50,7 @@ func RunAudioOutputServer() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug().Msg("audio output server started, waiting for connections")
|
logger.Info().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())
|
||||||
|
|
@ -65,16 +64,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ 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
|
||||||
|
|
@ -16,22 +16,22 @@ import (
|
||||||
type FieldConfig struct {
|
type FieldConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Required bool
|
Required bool
|
||||||
RequiredIf map[string]interface{}
|
RequiredIf map[string]any
|
||||||
OneOf []string
|
OneOf []string
|
||||||
ValidateTypes []string
|
ValidateTypes []string
|
||||||
Defaults interface{}
|
Defaults any
|
||||||
IsEmpty bool
|
IsEmpty bool
|
||||||
CurrentValue interface{}
|
CurrentValue any
|
||||||
TypeString string
|
TypeString string
|
||||||
Delegated bool
|
Delegated bool
|
||||||
shouldUpdateValue bool
|
shouldUpdateValue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetDefaultsAndValidate(config interface{}) error {
|
func SetDefaultsAndValidate(config any) error {
|
||||||
return setDefaultsAndValidate(config, true)
|
return setDefaultsAndValidate(config, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
func setDefaultsAndValidate(config any, 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 interface{}, 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]interface{}),
|
RequiredIf: make(map[string]any),
|
||||||
CurrentValue: fieldValue.Interface(),
|
CurrentValue: fieldValue.Interface(),
|
||||||
IsEmpty: false,
|
IsEmpty: false,
|
||||||
TypeString: fieldType,
|
TypeString: fieldType,
|
||||||
|
|
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, 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.Split(requiredIf, ",")
|
requiredIfParts := strings.SplitSeq(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 interface{}, isRoot bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateFields(config interface{}, fields map[string]FieldConfig) error {
|
func validateFields(config any, 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 interface{}) {
|
func (f *FieldConfig) populate(config any) {
|
||||||
// update the field if it's not empty
|
// update the field if it's not empty
|
||||||
if !f.shouldUpdateValue {
|
if !f.shouldUpdateValue {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func splitString(s string) []string {
|
||||||
return strings.Split(s, ",")
|
return strings.Split(s, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func toString(v interface{}) (string, error) {
|
func toString(v any) (string, error) {
|
||||||
switch v := v.(type) {
|
switch v := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
return v, nil
|
return v, nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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 interface{}, name string) string {
|
FormatPartValueByName: func(value any, 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.Split(strings.ToLower(env), ",")
|
scopes := strings.SplitSeq(strings.ToLower(env), ",")
|
||||||
for _, scope := range scopes {
|
for scope := range scopes {
|
||||||
l.scopeLevels[scope] = level
|
l.scopeLevels[scope] = level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ...interface{}) {
|
func (c pionLogger) Tracef(format string, args ...any) {
|
||||||
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 ...interface{}) {
|
func (c pionLogger) Debugf(format string, args ...any) {
|
||||||
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 ...interface{}) {
|
func (c pionLogger) Infof(format string, args ...any) {
|
||||||
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 ...interface{}) {
|
func (c pionLogger) Warnf(format string, args ...any) {
|
||||||
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 ...interface{}) {
|
func (c pionLogger) Errorf(format string, args ...any) {
|
||||||
c.logger.Error().Msgf(format, args...)
|
c.logger.Error().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
|
||||||
return &defaultLogger
|
return &defaultLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||||
// TODO: move rootLogger to logging package
|
// TODO: move rootLogger to logging package
|
||||||
if l == nil {
|
if l == nil {
|
||||||
l = &defaultLogger
|
l = &defaultLogger
|
||||||
|
|
|
||||||
|
|
@ -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.Split(string(lines), "\n") {
|
for line := range strings.SplitSeq(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
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsSame(a, b interface{}) bool {
|
func IsSame(a, b any) bool {
|
||||||
aJSON, err := json.Marshal(a)
|
aJSON, err := json.Marshal(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -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.Split(str, "\n") {
|
for line := range strings.SplitSeq(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.Fields(value) {
|
for ipStr := range strings.FieldsSeq(value) {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DHCPClient) getWatchPaths() []string {
|
func (c *DHCPClient) getWatchPaths() []string {
|
||||||
watchPaths := make(map[string]interface{})
|
watchPaths := make(map[string]any)
|
||||||
watchPaths[filepath.Dir(c.leaseFile)] = nil
|
watchPaths[filepath.Dir(c.leaseFile)] = nil
|
||||||
|
|
||||||
if c.pidFile != "" {
|
if c.pidFile != "" {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -61,6 +61,8 @@ 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
|
||||||
|
|
@ -68,7 +70,9 @@ const (
|
||||||
KeyboardLedMaskScrollLock = 1 << 2
|
KeyboardLedMaskScrollLock = 1 << 2
|
||||||
KeyboardLedMaskCompose = 1 << 3
|
KeyboardLedMaskCompose = 1 << 3
|
||||||
KeyboardLedMaskKana = 1 << 4
|
KeyboardLedMaskKana = 1 << 4
|
||||||
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
|
// power on/off LED is 5
|
||||||
|
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,
|
||||||
|
|
@ -81,6 +85,13 @@ 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 {
|
||||||
|
|
@ -91,27 +102,28 @@ 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(b byte) {
|
func (u *UsbGadget) updateKeyboardState(state byte) {
|
||||||
u.keyboardStateLock.Lock()
|
u.keyboardStateLock.Lock()
|
||||||
defer u.keyboardStateLock.Unlock()
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
if b&^ValidKeyboardLedMasks != 0 {
|
if state&^ValidKeyboardLedMasks != 0 {
|
||||||
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
|
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newState := getKeyboardState(b)
|
if u.keyboardState == state {
|
||||||
if reflect.DeepEqual(u.keyboardState, newState) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
|
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
|
||||||
u.keyboardState = newState
|
u.keyboardState = state
|
||||||
|
|
||||||
if u.onKeyboardStateChange != nil {
|
if u.onKeyboardStateChange != nil {
|
||||||
(*u.onKeyboardStateChange)(newState)
|
(*u.onKeyboardStateChange)(getKeyboardState(state))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +135,42 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
||||||
u.keyboardStateLock.Lock()
|
u.keyboardStateLock.Lock()
|
||||||
defer u.keyboardStateLock.Unlock()
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
return u.keyboardState
|
return getKeyboardState(u.keyboardState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) GetKeysDownState() KeysDownState {
|
||||||
|
u.keyboardStateLock.Lock()
|
||||||
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
|
return u.keysDownState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
|
||||||
|
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("acquiring keyboardStateLock for updateKeyDownState")
|
||||||
|
|
||||||
|
// this is intentional to unlock keyboard state lock before onKeysDownChange callback
|
||||||
|
{
|
||||||
|
u.keyboardStateLock.Lock()
|
||||||
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
|
if u.keysDownState.Modifier == state.Modifier &&
|
||||||
|
bytes.Equal(u.keysDownState.Keys, state.Keys) {
|
||||||
|
return // No change in key down state
|
||||||
|
}
|
||||||
|
|
||||||
|
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
|
||||||
|
u.keysDownState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.onKeysDownChange != nil {
|
||||||
|
u.log.Trace().Interface("state", state).Msg("calling onKeysDownChange")
|
||||||
|
(*u.onKeysDownChange)(state)
|
||||||
|
u.log.Trace().Interface("state", state).Msg("onKeysDownChange called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
|
||||||
|
u.onKeysDownChange = &f
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) listenKeyboardEvents() {
|
func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
|
|
@ -142,7 +189,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")
|
l.Trace().Msg("reading from keyboard for LED state changes")
|
||||||
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
|
||||||
|
|
@ -159,7 +206,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
}
|
}
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||||
|
|
||||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
l.Trace().Int("n", n).Uints8("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
|
||||||
|
|
@ -195,12 +242,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
|
||||||
return u.openKeyboardHidFile()
|
return u.openKeyboardHidFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
|
||||||
if err := u.openKeyboardHidFile(); err != nil {
|
if err := u.openKeyboardHidFile(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.keyboardHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
|
||||||
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
|
||||||
|
|
@ -210,22 +257,145 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
|
||||||
|
// if we just reported an error roll over, we should clear the keys
|
||||||
|
if keys[0] == hidErrorRollOver {
|
||||||
|
for i := range keys {
|
||||||
|
keys[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downState := KeysDownState{
|
||||||
|
Modifier: modifier,
|
||||||
|
Keys: []byte(keys[:]),
|
||||||
|
}
|
||||||
|
u.updateKeyDownState(downState)
|
||||||
|
return downState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) {
|
||||||
u.keyboardLock.Lock()
|
u.keyboardLock.Lock()
|
||||||
defer u.keyboardLock.Unlock()
|
defer u.keyboardLock.Unlock()
|
||||||
|
defer u.resetUserInputTime()
|
||||||
|
|
||||||
if len(keys) > 6 {
|
if len(keys) > hidKeyBufferSize {
|
||||||
keys = keys[:6]
|
keys = keys[:hidKeyBufferSize]
|
||||||
}
|
}
|
||||||
if len(keys) < 6 {
|
if len(keys) < hidKeyBufferSize {
|
||||||
keys = append(keys, make([]uint8, 6-len(keys))...)
|
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
err := u.keyboardWriteHidFile(modifier, keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
|
||||||
}
|
}
|
||||||
|
|
||||||
u.resetUserInputTime()
|
return u.UpdateKeysDown(modifier, keys), err
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.absMouseHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.absMouseHidFile, data)
|
||||||
if err != nil {
|
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, y int, buttons uint8) error {
|
func (u *UsbGadget) AbsMouseReport(x int, 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
|
||||||
uint8(x), // X Low Byte
|
byte(x), // X Low Byte
|
||||||
uint8(x >> 8), // X High Byte
|
byte(x >> 8), // X High Byte
|
||||||
uint8(y), // Y Low Byte
|
byte(y), // Y Low Byte
|
||||||
uint8(y >> 8), // Y High Byte
|
byte(y >> 8), // Y High Byte
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.relMouseHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.relMouseHidFile, data)
|
||||||
if err != nil {
|
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, my int8, buttons uint8) error {
|
func (u *UsbGadget) RelMouseReport(mx int8, 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
|
||||||
uint8(mx), // X
|
byte(mx), // X
|
||||||
uint8(my), // Y
|
byte(my), // Y
|
||||||
0, // Wheel
|
0, // Wheel
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -42,6 +42,11 @@ 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
|
||||||
|
|
@ -61,7 +66,9 @@ type UsbGadget struct {
|
||||||
relMouseHidFile *os.File
|
relMouseHidFile *os.File
|
||||||
relMouseLock sync.Mutex
|
relMouseLock sync.Mutex
|
||||||
|
|
||||||
keyboardState KeyboardState
|
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
|
||||||
|
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
|
||||||
|
|
@ -78,6 +85,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -183,7 +191,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
||||||
txLock: sync.Mutex{},
|
txLock: sync.Mutex{},
|
||||||
keyboardStateCtx: keyboardCtx,
|
keyboardStateCtx: keyboardCtx,
|
||||||
keyboardStateCancel: keyboardCancel,
|
keyboardStateCancel: keyboardCancel,
|
||||||
keyboardState: KeyboardState{},
|
keyboardState: 0,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,43 @@ 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...)
|
||||||
|
|
@ -81,7 +110,32 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
|
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
|
||||||
|
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = file.Write(data)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
u.logWithSuppression(
|
||||||
|
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
|
||||||
|
1000,
|
||||||
|
u.log,
|
||||||
|
err,
|
||||||
|
"write timed out: %s",
|
||||||
|
file.Name(),
|
||||||
|
)
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {
|
||||||
u.logSuppressionLock.Lock()
|
u.logSuppressionLock.Lock()
|
||||||
defer u.logSuppressionLock.Unlock()
|
defer u.logSuppressionLock.Unlock()
|
||||||
|
|
||||||
|
|
|
||||||
109
jsonrpc.go
|
|
@ -13,6 +13,7 @@ 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"
|
||||||
|
|
@ -24,21 +25,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]interface{} `json:"params,omitempty"`
|
Params map[string]any `json:"params,omitempty"`
|
||||||
ID interface{} `json:"id,omitempty"`
|
ID any `json:"id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JSONRPCResponse struct {
|
type JSONRPCResponse struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Result interface{} `json:"result,omitempty"`
|
Result any `json:"result,omitempty"`
|
||||||
Error interface{} `json:"error,omitempty"`
|
Error any `json:"error,omitempty"`
|
||||||
ID interface{} `json:"id"`
|
ID any `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 interface{} `json:"params,omitempty"`
|
Params any `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayRotationSettings struct {
|
type DisplayRotationSettings struct {
|
||||||
|
|
@ -64,7 +65,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
request := JSONRPCEvent{
|
request := JSONRPCEvent{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Method: event,
|
Method: event,
|
||||||
|
|
@ -85,7 +86,7 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||||
Str("data", requestString).
|
Str("data", requestString).
|
||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
scopedLogger.Info().Msg("sending JSONRPC event")
|
scopedLogger.Trace().Msg("sending JSONRPC event")
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(requestString)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -105,7 +106,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
|
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Error: map[string]interface{}{
|
Error: map[string]any{
|
||||||
"code": -32700,
|
"code": -32700,
|
||||||
"message": "Parse error",
|
"message": "Parse error",
|
||||||
},
|
},
|
||||||
|
|
@ -159,7 +160,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]interface{}{
|
Error: map[string]any{
|
||||||
"code": -32601,
|
"code": -32601,
|
||||||
"message": "Method not found",
|
"message": "Method not found",
|
||||||
},
|
},
|
||||||
|
|
@ -169,13 +170,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopedLogger.Trace().Msg("Calling RPC handler")
|
result, err := callRPCHandler(scopedLogger, handler, request.Params)
|
||||||
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]interface{}{
|
Error: map[string]any{
|
||||||
"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]interface{}{"quality_factor": factor})
|
var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"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]interface{}{"edid": edid})
|
_, err := CallCtrlAction("set_edid", map[string]any{"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 interface{}
|
Func any
|
||||||
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(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
|
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, 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(handler RPCHandler, params map[string]interface{}) (result i
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Call the handler
|
// Call the handler
|
||||||
result, err = riskyCallRPCHandler(handler, params)
|
result, err = riskyCallRPCHandler(logger, handler, params)
|
||||||
return result, err
|
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
|
||||||
}
|
}
|
||||||
|
|
||||||
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
|
||||||
handlerValue := reflect.ValueOf(handler.Func)
|
handlerValue := reflect.ValueOf(handler.Func)
|
||||||
handlerType := handlerValue.Type()
|
handlerType := handlerValue.Type()
|
||||||
|
|
||||||
|
|
@ -535,20 +535,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
|
|
||||||
numParams := handlerType.NumIn()
|
numParams := handlerType.NumIn()
|
||||||
args := make([]reflect.Value, numParams)
|
paramNames := handler.Params // Get the parameter names from the RPCHandler
|
||||||
// Get the parameter names from the RPCHandler
|
|
||||||
paramNames := handler.Params
|
|
||||||
|
|
||||||
if len(paramNames) != numParams {
|
if len(paramNames) != numParams {
|
||||||
return nil, errors.New("mismatch between handler parameters and defined parameter names")
|
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
|
||||||
|
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < numParams; i++ {
|
args := make([]reflect.Value, numParams)
|
||||||
|
|
||||||
|
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 {
|
||||||
return nil, errors.New("missing parameter: " + paramName)
|
err := fmt.Errorf("missing parameter: %s", paramName)
|
||||||
|
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
convertedValue := reflect.ValueOf(paramValue)
|
convertedValue := reflect.ValueOf(paramValue)
|
||||||
|
|
@ -565,7 +569,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
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", intValue)
|
return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
|
||||||
}
|
}
|
||||||
newSlice.Index(j).SetUint(uint64(intValue))
|
newSlice.Index(j).SetUint(uint64(intValue))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -581,12 +585,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
} 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", err)
|
return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
|
||||||
}
|
}
|
||||||
|
|
||||||
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", err)
|
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
|
||||||
}
|
}
|
||||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -597,6 +601,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Trace().Msg("Calling RPC handler")
|
||||||
results := handlerValue.Call(args)
|
results := handlerValue.Call(args)
|
||||||
|
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
|
|
@ -604,23 +609,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 1 {
|
if len(results) == 1 {
|
||||||
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
if ok, err := asError(results[0]); ok {
|
||||||
if !results[0].IsNil() {
|
return nil, err
|
||||||
return nil, results[0].Interface().(error)
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
return results[0].Interface(), nil
|
return results[0].Interface(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
if len(results) == 2 {
|
||||||
if !results[1].IsNil() {
|
if ok, err := asError(results[1]); ok {
|
||||||
return nil, results[1].Interface().(error)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return results[0].Interface(), nil
|
return results[0].Interface(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("unexpected return values from handler")
|
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
func asError(value reflect.Value) (bool, error) {
|
||||||
|
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return true, value.Interface().(error)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||||
|
|
@ -1130,7 +1144,7 @@ func rpcSetKeyboardLayout(layout string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getKeyboardMacros() (interface{}, error) {
|
func getKeyboardMacros() (any, error) {
|
||||||
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
||||||
copy(macros, config.KeyboardMacros)
|
copy(macros, config.KeyboardMacros)
|
||||||
|
|
||||||
|
|
@ -1138,10 +1152,10 @@ func getKeyboardMacros() (interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyboardMacrosParams struct {
|
type KeyboardMacrosParams struct {
|
||||||
Macros []interface{} `json:"macros"`
|
Macros []any `json:"macros"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
func setKeyboardMacros(params KeyboardMacrosParams) (any, 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")
|
||||||
}
|
}
|
||||||
|
|
@ -1149,7 +1163,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, 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]interface{})
|
macroMap, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid macro at index %d", i)
|
return nil, fmt.Errorf("invalid macro at index %d", i)
|
||||||
}
|
}
|
||||||
|
|
@ -1167,16 +1181,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
steps := []KeyboardMacroStep{}
|
steps := []KeyboardMacroStep{}
|
||||||
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
|
if stepsArray, ok := macroMap["steps"].([]any); ok {
|
||||||
for _, stepItem := range stepsArray {
|
for _, stepItem := range stepsArray {
|
||||||
stepMap, ok := stepItem.(map[string]interface{})
|
stepMap, ok := stepItem.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
step := KeyboardMacroStep{}
|
step := KeyboardMacroStep{}
|
||||||
|
|
||||||
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
|
if keysArray, ok := stepMap["keys"].([]any); 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)
|
||||||
|
|
@ -1184,7 +1198,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
|
if modsArray, ok := stepMap["modifiers"].([]any); 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)
|
||||||
|
|
@ -1254,6 +1268,8 @@ 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"}},
|
||||||
|
|
@ -1294,7 +1310,6 @@ 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
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||||
return logging.ErrorfL(l, format, err, args...)
|
return logging.ErrorfL(l, format, err, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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
|
|
@ -1,46 +1,255 @@
|
||||||
//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/rs/zerolog"
|
"github.com/jetkvm/kvm/resource"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
type nativeOutput struct {
|
var ctrlSocketConn net.Conn
|
||||||
logger *zerolog.Logger
|
|
||||||
|
type CtrlAction struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Seq int32 `json:"seq,omitempty"`
|
||||||
|
Params map[string]any `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *nativeOutput) Write(p []byte) (int, error) {
|
type CtrlResponse struct {
|
||||||
n.logger.Debug().Str("output", string(p)).Msg("native binary output")
|
Seq int32 `json:"seq,omitempty"`
|
||||||
return len(p), nil
|
Error string `json:"error,omitempty"`
|
||||||
|
Errno int32 `json:"errno,omitempty"`
|
||||||
|
Result map[string]any `json:"result,omitempty"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 startNativeBinary(binaryPath string) (*exec.Cmd, error) {
|
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
|
||||||
cmd := exec.Command(binaryPath)
|
lock.Lock()
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
defer lock.Unlock()
|
||||||
Pdeathsig: syscall.SIGTERM,
|
ctrlAction := CtrlAction{
|
||||||
|
Action: action,
|
||||||
|
Seq: seq,
|
||||||
|
Params: params,
|
||||||
}
|
}
|
||||||
cmd.Stdout = &nativeOutput{logger: nativeLogger}
|
|
||||||
cmd.Stderr = &nativeOutput{logger: nativeLogger}
|
|
||||||
|
|
||||||
err := cmd.Start()
|
responseChan := make(chan *CtrlResponse)
|
||||||
|
ongoingRequests[seq] = responseChan
|
||||||
|
seq++
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(ctrlAction)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
delete(ongoingRequests, ctrlAction.Seq)
|
||||||
|
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd, nil
|
scopedLogger := nativeLogger.With().
|
||||||
|
Str("action", ctrlAction.Action).
|
||||||
|
Interface("params", ctrlAction.Params).Logger()
|
||||||
|
|
||||||
|
scopedLogger.Debug().Msg("sending ctrl action")
|
||||||
|
|
||||||
|
err = WriteCtrlMessage(jsonData)
|
||||||
|
if err != nil {
|
||||||
|
delete(ongoingRequests, ctrlAction.Seq)
|
||||||
|
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case response := <-responseChan:
|
||||||
|
delete(ongoingRequests, seq)
|
||||||
|
if response.Error != "" {
|
||||||
|
return nil, ErrorfL(
|
||||||
|
&scopedLogger,
|
||||||
|
"error native response: %s",
|
||||||
|
errors.New(response.Error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
close(responseChan)
|
||||||
|
delete(ongoingRequests, seq)
|
||||||
|
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteCtrlMessage(message []byte) error {
|
||||||
|
if ctrlSocketConn == nil {
|
||||||
|
return fmt.Errorf("ctrl socket not conn ected")
|
||||||
|
}
|
||||||
|
_, err := ctrlSocketConn.Write(message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var nativeCtrlSocketListener net.Listener //nolint:unused
|
||||||
|
var nativeVideoSocketListener net.Listener //nolint:unused
|
||||||
|
|
||||||
|
var ctrlClientConnected = make(chan struct{})
|
||||||
|
|
||||||
|
func waitCtrlClientConnected() {
|
||||||
|
<-ctrlClientConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
|
||||||
|
scopedLogger := nativeLogger.With().
|
||||||
|
Str("socket_path", socketPath).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
// Remove the socket file if it already exists
|
||||||
|
if _, err := os.Stat(socketPath); err == nil {
|
||||||
|
if err := os.Remove(socketPath); err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("unixpacket", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to start server")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopedLogger.Info().Msg("server listening")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isCtrl {
|
||||||
|
// check if the channel is closed
|
||||||
|
select {
|
||||||
|
case <-ctrlClientConnected:
|
||||||
|
scopedLogger.Debug().Msg("ctrl client reconnected")
|
||||||
|
default:
|
||||||
|
close(ctrlClientConnected)
|
||||||
|
scopedLogger.Debug().Msg("first native ctrl socket client connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go handleClient(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartNativeCtrlSocketServer() {
|
||||||
|
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
|
||||||
|
nativeLogger.Debug().Msg("native app ctrl sock started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartNativeVideoSocketServer() {
|
||||||
|
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
|
||||||
|
nativeLogger.Debug().Msg("native app video sock started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCtrlClient(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
scopedLogger := nativeLogger.With().
|
||||||
|
Str("addr", conn.RemoteAddr().String()).
|
||||||
|
Str("type", "ctrl").
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
scopedLogger.Info().Msg("native ctrl socket client connected")
|
||||||
|
if ctrlSocketConn != nil {
|
||||||
|
scopedLogger.Debug().Msg("closing existing native socket connection")
|
||||||
|
ctrlSocketConn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrlSocketConn = conn
|
||||||
|
|
||||||
|
// Restore HDMI EDID if applicable
|
||||||
|
go restoreHdmiEdid()
|
||||||
|
|
||||||
|
readBuf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(readBuf)
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
readMsg := string(readBuf[:n])
|
||||||
|
|
||||||
|
ctrlResp := CtrlResponse{}
|
||||||
|
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
||||||
|
|
||||||
|
if ctrlResp.Seq != 0 {
|
||||||
|
responseChan, ok := ongoingRequests[ctrlResp.Seq]
|
||||||
|
if ok {
|
||||||
|
responseChan <- &ctrlResp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch ctrlResp.Event {
|
||||||
|
case "video_input_state":
|
||||||
|
HandleVideoStateMessage(ctrlResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scopedLogger.Debug().Msg("ctrl sock disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleVideoClient(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
scopedLogger := nativeLogger.With().
|
||||||
|
Str("addr", conn.RemoteAddr().String()).
|
||||||
|
Str("type", "video").
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
scopedLogger.Info().Msg("native video socket client connected")
|
||||||
|
|
||||||
|
inboundPacket := make([]byte, maxFrameSize)
|
||||||
|
lastFrame := time.Now()
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(inboundPacket)
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("error during read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
sinceLastFrame := now.Sub(lastFrame)
|
||||||
|
lastFrame = now
|
||||||
|
if currentSession != nil {
|
||||||
|
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("error writing sample")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
|
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
|
||||||
|
|
@ -142,3 +351,87 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
//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
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,5 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
|
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
|
||||||
return nil, fmt.Errorf("startNativeBinary is only supported on Linux")
|
return nil, fmt.Errorf("not supported")
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractAndRunNativeBin() error {
|
|
||||||
return fmt.Errorf("ExtractAndRunNativeBin is only supported on Linux")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
343
native_shared.go
|
|
@ -1,343 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -66,6 +66,10 @@ 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: {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +27,14 @@
|
||||||
/>
|
/>
|
||||||
<title>JetKVM</title>
|
<title>JetKVM</title>
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="JetKVM" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#051946" />
|
||||||
|
<meta name="description" content="A web-based KVM console for managing remote servers." />
|
||||||
<script>
|
<script>
|
||||||
// Initial theme setup
|
// Initial theme setup
|
||||||
document.documentElement.classList.toggle(
|
document.documentElement.classList.toggle(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "kvm-ui",
|
"name": "kvm-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2025.08.07.001",
|
"version": "2025.09.03.2100",
|
||||||
"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.13",
|
"dayjs": "^1.11.18",
|
||||||
"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.5.2",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router": "^7.8.2",
|
||||||
"react-simple-keyboard": "^3.8.106",
|
"react-simple-keyboard": "^3.8.119",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^3.1.2",
|
||||||
"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.1",
|
"@eslint/compat": "^1.3.2",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.34.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@types/react": "^19.1.9",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/validator": "^13.15.2",
|
"@types/validator": "^13.15.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
||||||
"@typescript-eslint/parser": "^8.39.0",
|
"@typescript-eslint/parser": "^8.42.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^4.0.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.34.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.11",
|
"tailwindcss": "^4.1.12",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.4",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 972 B |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
|
@ -1,8 +0,0 @@
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
|
|
||||||
fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
|
|
||||||
fill="black"/>
|
|
||||||
<path d="M4 13H20V18H4V13Z" fill="black"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 511 B |
|
|
@ -53,17 +53,13 @@ export default function Actionbar({
|
||||||
microphone: MicrophoneHookReturn;
|
microphone: MicrophoneHookReturn;
|
||||||
}) {
|
}) {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
||||||
|
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(state => state.developerMode);
|
const { developerMode } = useSettingsStore();
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -74,13 +70,13 @@ export default function Actionbar({
|
||||||
isOpen.current = open;
|
isOpen.current = open;
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDisableFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
console.log("Popover is closing. Returning focus trap to video");
|
console.debug("Popover is closing. Returning focus trap to video");
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setDisableFocusTrap],
|
[setDisableVideoFocusTrap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use WebSocket-based audio events for real-time updates
|
// Use WebSocket-based audio events for real-time updates
|
||||||
|
|
@ -118,7 +114,7 @@ export default function Actionbar({
|
||||||
text="Paste text"
|
text="Paste text"
|
||||||
LeadingIcon={MdOutlineContentPasteGo}
|
LeadingIcon={MdOutlineContentPasteGo}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -160,7 +156,7 @@ export default function Actionbar({
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -191,7 +187,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Wake on LAN"
|
text="Wake on LAN"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
LeadingIcon={({ className }) => (
|
LeadingIcon={({ className }) => (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -241,7 +237,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Virtual Keyboard"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={FaKeyboard}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,7 +251,7 @@ export default function Actionbar({
|
||||||
text="Extension"
|
text="Extension"
|
||||||
LeadingIcon={LuCable}
|
LeadingIcon={LuCable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -280,7 +276,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Virtual Keyboard"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={FaKeyboard}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
|
|
@ -306,7 +302,10 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Settings"
|
text="Settings"
|
||||||
LeadingIcon={LuSettings}
|
LeadingIcon={LuSettings}
|
||||||
onClick={() => navigateTo("/settings")}
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
navigateTo("/settings")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -342,7 +341,7 @@ export default function Actionbar({
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isAudioEnabledInUsb) {
|
if (isAudioEnabledInUsb) {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigation, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import { GoogleIcon } from "@components/Icons";
|
import { GoogleIcon } from "@components/Icons";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { JSX } from "react";
|
import React, { JSX } from "react";
|
||||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
import { Link, useNavigation } from "react-router";
|
||||||
|
import type { FetcherWithComponents, LinkProps } from "react-router";
|
||||||
|
|
||||||
import ExtLink from "@/components/ExtLink";
|
import ExtLink from "@/components/ExtLink";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
@ -175,7 +176,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",
|
"group outline-hidden cursor-pointer",
|
||||||
props.fullWidth ? "w-full" : "",
|
props.fullWidth ? "w-full" : "",
|
||||||
loading ? "pointer-events-none" : "",
|
loading ? "pointer-events-none" : "",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { FetcherWithComponents, useNavigation } from "react-router-dom";
|
import { useNavigation } from "react-router";
|
||||||
|
import type { FetcherWithComponents } from "react-router";
|
||||||
|
|
||||||
export default function Fieldset({
|
export default function Fieldset({
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router";
|
||||||
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(state => state.usbState);
|
const { usbState } = useHidStore();
|
||||||
|
|
||||||
// for testing
|
// for testing
|
||||||
//userEmail = "user@example.org";
|
//userEmail = "user@example.org";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,64 +7,70 @@ 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 activeKeys = useHidStore(state => state.activeKeys);
|
const { keysDownState } = useHidStore();
|
||||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
const { mouseX, mouseY, mouseMove } = useMouseStore();
|
||||||
const mouseX = useMouseStore(state => state.mouseX);
|
const { rpcHidStatus } = useHidRpc();
|
||||||
const mouseY = useMouseStore(state => state.mouseY);
|
|
||||||
const mouseMove = useMouseStore(state => state.mouseMove);
|
|
||||||
|
|
||||||
const videoClientSize = useVideoStore(
|
const videoClientSize = useVideoStore(
|
||||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoSize = useVideoStore(
|
const videoSize = useVideoStore(
|
||||||
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
||||||
const settings = useSettingsStore();
|
|
||||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rpcDataChannel) return;
|
if (!rpcDataChannel) return;
|
||||||
rpcDataChannel.onclose = () => { /* RPC data channel closed */ };
|
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
||||||
rpcDataChannel.onerror = () => { /* Error on RPC data channel */ };
|
rpcDataChannel.onerror = (e: Event) =>
|
||||||
|
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
||||||
}, [rpcDataChannel]);
|
}, [rpcDataChannel]);
|
||||||
|
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const { keyboardLedState, usbState } = useHidStore();
|
||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
const { isTurnServerInUse } = useRTCStore();
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
const { hdmiState } = useVideoStore();
|
||||||
|
|
||||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
const displayKeys = useMemo(() => {
|
||||||
|
if (!showPressedKeys)
|
||||||
|
return "";
|
||||||
|
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const activeModifierMask = keysDownState.modifier || 0;
|
||||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
const keysDown = keysDownState.keys || [];
|
||||||
|
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
|
||||||
|
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
|
||||||
|
|
||||||
|
return [...modifierNames, ...keyNames].join(", ");
|
||||||
|
}, [keysDownState, showPressedKeys]);
|
||||||
|
|
||||||
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">
|
||||||
{settings.debugMode ? (
|
{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}
|
||||||
|
|
||||||
{settings.debugMode ? (
|
{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}
|
||||||
|
|
||||||
{(settings.debugMode && settings.mouseMode == "absolute") ? (
|
{(debugMode && 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">
|
||||||
|
|
@ -73,7 +79,7 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(settings.debugMode && settings.mouseMode == "relative") ? (
|
{(debugMode && 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">
|
||||||
|
|
@ -84,31 +90,30 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings.debugMode && (
|
{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>
|
||||||
)}
|
)}
|
||||||
{settings.debugMode && (
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -121,23 +126,10 @@ 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",
|
||||||
keyboardLedSync !== "browser"
|
keyboardLedState.caps_lock
|
||||||
? "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",
|
||||||
)}
|
)}
|
||||||
|
|
@ -147,7 +139,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",
|
||||||
)}
|
)}
|
||||||
|
|
@ -157,23 +149,28 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -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-dom";
|
import { Link } from "react-router";
|
||||||
import { LuEllipsisVertical } from "react-icons/lu";
|
import { LuEllipsisVertical } from "react-icons/lu";
|
||||||
|
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
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 { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
|
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||||
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 FieldLabel from "@/components/FieldLabel";
|
import { KeySequence } from "@/hooks/stores";
|
||||||
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
|
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -44,6 +45,7 @@ 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);
|
||||||
|
|
@ -234,6 +236,7 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
|
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 { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
|
||||||
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
||||||
|
import { KeyboardLayout } from "@/keyboardLayouts";
|
||||||
|
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"),
|
||||||
|
|
@ -67,6 +62,7 @@ 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[] => {
|
||||||
|
|
@ -84,9 +80,22 @@ export function MacroStepCard({
|
||||||
keyQuery,
|
keyQuery,
|
||||||
onModifierChange,
|
onModifierChange,
|
||||||
onDelayChange,
|
onDelayChange,
|
||||||
isLastStep
|
isLastStep,
|
||||||
|
keyboard
|
||||||
}: MacroStepCardProps) {
|
}: MacroStepCardProps) {
|
||||||
const getFilteredKeys = () => {
|
const { keyDisplayMap } = keyboard;
|
||||||
|
|
||||||
|
const keyOptions = useMemo(() =>
|
||||||
|
Object.keys(keys)
|
||||||
|
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
||||||
|
.map(key => ({
|
||||||
|
value: key,
|
||||||
|
label: keyDisplayMap[key] || key,
|
||||||
|
})),
|
||||||
|
[keyDisplayMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredKeys = useMemo(() => {
|
||||||
const 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));
|
||||||
|
|
||||||
|
|
@ -95,7 +104,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">
|
||||||
|
|
@ -204,7 +213,7 @@ export function MacroStepCard({
|
||||||
}}
|
}}
|
||||||
displayValue={() => keyQuery}
|
displayValue={() => keyQuery}
|
||||||
onInputChange={onKeyQueryChange}
|
onInputChange={onKeyQueryChange}
|
||||||
options={getFilteredKeys}
|
options={() => filteredKeys}
|
||||||
disabledMessage="Max keys reached"
|
disabledMessage="Max keys reached"
|
||||||
size="SM"
|
size="SM"
|
||||||
immediate
|
immediate
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
|
|
|
||||||
|
|
@ -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 } from "react";
|
import { useEffect, useMemo } 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,21 +65,22 @@ function Terminal({
|
||||||
readonly dataChannel: RTCDataChannel;
|
readonly dataChannel: RTCDataChannel;
|
||||||
readonly type: AvailableTerminalTypes;
|
readonly type: AvailableTerminalTypes;
|
||||||
}) {
|
}) {
|
||||||
const enableTerminal = useUiStore(state => state.terminalType == type);
|
const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
|
||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
|
||||||
|
|
||||||
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
|
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
|
||||||
|
|
||||||
|
const isTerminalTypeEnabled = useMemo(() => {
|
||||||
|
return terminalType == type;
|
||||||
|
}, [terminalType, type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDisableVideoFocusTrap(enableTerminal);
|
setDisableVideoFocusTrap(isTerminalTypeEnabled);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
};
|
};
|
||||||
}, [enableTerminal, setDisableVideoFocusTrap]);
|
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
|
||||||
|
|
||||||
const readyState = dataChannel.readyState;
|
const readyState = dataChannel.readyState;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -175,9 +176,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":
|
||||||
!enableTerminal,
|
!isTerminalTypeEnabled,
|
||||||
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
|
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
|
||||||
enableTerminal,
|
isTerminalTypeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ 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 { HidState } from "@/hooks/stores";
|
import { USBStates } from "@/hooks/stores";
|
||||||
|
|
||||||
type USBStates = HidState["usbState"];
|
|
||||||
|
|
||||||
type StatusProps = Record<
|
type StatusProps = Record<
|
||||||
USBStates,
|
USBStates,
|
||||||
|
|
@ -67,7 +65,7 @@ export default function USBStateStatus({
|
||||||
};
|
};
|
||||||
const props = StatusCardProps[state];
|
const props = StatusCardProps[state];
|
||||||
if (!props) {
|
if (!props) {
|
||||||
console.log("Unsupported USB state: ", state);
|
console.warn("Unsupported USB state: ", state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ export function UsbDeviceSetting() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
const handlePresetChange = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newPreset = e.target.value;
|
const newPreset = e.target.value;
|
||||||
setSelectedPreset(newPreset);
|
setSelectedPreset(newPreset);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", {}, async (resp: JsonRpcResponse) => {
|
send("getDeviceID", {}, (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"}`,
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,74 @@
|
||||||
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 } from "@components/Button";
|
import { Button, LinkButton } 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, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
|
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 showAttachedVirtualKeyboard = useUiStore(
|
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } =
|
||||||
state => state.isAttachedVirtualKeyboardVisible,
|
useUiStore();
|
||||||
);
|
const { keyboardLedState, keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } =
|
||||||
const setShowAttachedVirtualKeyboard = useUiStore(
|
useHidStore();
|
||||||
state => state.setAttachedVirtualKeyboardVisibility,
|
const { handleKeyPress, executeMacro } = useKeyboard();
|
||||||
);
|
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 isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
|
const keyDisplayMap = useMemo(() => {
|
||||||
|
return selectedKeyboard.keyDisplayMap;
|
||||||
|
}, [selectedKeyboard]);
|
||||||
|
|
||||||
// HID related states
|
const virtualKeyboard = useMemo(() => {
|
||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
return selectedKeyboard.virtualKeyboard;
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
}, [selectedKeyboard]);
|
||||||
const isKeyboardLedManagedByHost = useMemo(() =>
|
|
||||||
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
|
|
||||||
[keyboardLedSync, keyboardLedStateSyncAvailable],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
|
const { isShiftActive } = useMemo(() => {
|
||||||
|
return decodeModifiers(keysDownState.modifier);
|
||||||
|
}, [keysDownState]);
|
||||||
|
|
||||||
|
const isCapsLockActive = useMemo(() => {
|
||||||
|
return keyboardLedState.caps_lock;
|
||||||
|
}, [keyboardLedState]);
|
||||||
|
|
||||||
|
const mainLayoutName = useMemo(() => {
|
||||||
|
// if you have the CapsLock "latched", then the shift state is inverted
|
||||||
|
const effectiveShift = isCapsLockActive ? false === isShiftActive : isShiftActive;
|
||||||
|
return effectiveShift ? "shift" : "default";
|
||||||
|
}, [isCapsLockActive, isShiftActive]);
|
||||||
|
|
||||||
|
const keyNamesForDownKeys = useMemo(() => {
|
||||||
|
const activeModifierMask = keysDownState.modifier || 0;
|
||||||
|
const modifierNames = Object.entries(modifiers)
|
||||||
|
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
|
||||||
|
.map(([name, _]) => name);
|
||||||
|
|
||||||
|
const keysDown = keysDownState.keys || [];
|
||||||
|
const keyNames = Object.entries(keys)
|
||||||
|
.filter(([_, value]) => keysDown.includes(value))
|
||||||
|
.map(([name, _]) => name);
|
||||||
|
|
||||||
|
return [...modifierNames, ...keyNames, " "]; // we have to have at least one space to avoid keyboard whining
|
||||||
|
}, [keysDownState]);
|
||||||
|
|
||||||
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
|
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
|
||||||
if (!keyboardRef.current) return;
|
if (!keyboardRef.current) return;
|
||||||
|
|
@ -97,6 +113,9 @@ 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);
|
||||||
|
|
@ -121,96 +140,76 @@ function KeyboardWrapper() {
|
||||||
document.removeEventListener("mousemove", onDrag);
|
document.removeEventListener("mousemove", onDrag);
|
||||||
document.removeEventListener("touchmove", onDrag);
|
document.removeEventListener("touchmove", onDrag);
|
||||||
};
|
};
|
||||||
}, [endDrag, onDrag, startDrag]);
|
}, [isAttachedVirtualKeyboardVisible, endDrag, onDrag, startDrag]);
|
||||||
|
|
||||||
|
const onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
e?.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(key: string) => {
|
async (key: string, e: MouseEvent | undefined) => {
|
||||||
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
|
e?.preventDefault();
|
||||||
const isKeyCaps = key === "CapsLock";
|
e?.stopPropagation();
|
||||||
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") {
|
||||||
sendKeyboardEvent(
|
await executeMacro([
|
||||||
[keys["Delete"]],
|
{ keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 },
|
||||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
]);
|
||||||
);
|
|
||||||
setTimeout(resetKeyboardState, 100);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "AltMetaEscape") {
|
if (key === "AltMetaEscape") {
|
||||||
sendKeyboardEvent(
|
await executeMacro([
|
||||||
[keys["Escape"]],
|
{ keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 },
|
||||||
[modifiers["MetaLeft"], modifiers["AltLeft"]],
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(resetKeyboardState, 100);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "CtrlAltBackspace") {
|
if (key === "CtrlAltBackspace") {
|
||||||
sendKeyboardEvent(
|
await executeMacro([
|
||||||
[keys["Backspace"]],
|
{ keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 },
|
||||||
[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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKeyShift || isKeyCaps) {
|
// otherwise, just treat it as a down+up pair
|
||||||
toggleLayout();
|
const cleanKey = key.replace(/[()]/g, "");
|
||||||
|
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
|
||||||
if (isCapsLockActive) {
|
handleKeyPress(keys[cleanKey], true);
|
||||||
if (!isKeyboardLedManagedByHost) {
|
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
[executeMacro, handleKeyPress, keyNamesForDownKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
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: virtualKeyboard ? "0px" : `-${350}px`,
|
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{virtualKeyboard && (
|
{isVirtualKeyboardEnabled && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: "100%" }}
|
initial={{ opacity: 0, y: "100%" }}
|
||||||
animate={{ opacity: 1, y: "0%" }}
|
animate={{ opacity: 1, y: "0%" }}
|
||||||
|
|
@ -222,51 +221,62 @@ function KeyboardWrapper() {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
!showAttachedVirtualKeyboard
|
!isAttachedVirtualKeyboardVisible
|
||||||
? "fixed left-0 top-0 z-50 select-none"
|
? "fixed top-0 left-0 z-10 select-none"
|
||||||
: "relative",
|
: "relative",
|
||||||
)}
|
)}
|
||||||
ref={keyboardRef}
|
ref={keyboardRef}
|
||||||
style={{
|
style={{
|
||||||
...(!showAttachedVirtualKeyboard
|
...(!isAttachedVirtualKeyboardVisible
|
||||||
? { 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": showAttachedVirtualKeyboard,
|
"rounded-none": isAttachedVirtualKeyboardVisible,
|
||||||
|
"keyboard-detached": !isAttachedVirtualKeyboardVisible,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
|
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-4 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||||
<div className="absolute left-2 flex items-center gap-x-2">
|
<div className="absolute left-2 flex items-center gap-x-2">
|
||||||
{showAttachedVirtualKeyboard ? (
|
{isAttachedVirtualKeyboardVisible ? (
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Detach"
|
text="Detach"
|
||||||
onClick={() => setShowAttachedVirtualKeyboard(false)}
|
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Attach"
|
text="Attach"
|
||||||
LeadingIcon={AttachIcon}
|
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
|
||||||
onClick={() => setShowAttachedVirtualKeyboard(true)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
|
||||||
Virtual Keyboard
|
Virtual Keyboard
|
||||||
</h2>
|
</h2>
|
||||||
<div className="absolute right-2">
|
<div className="absolute right-2 flex items-center gap-x-2">
|
||||||
|
<div className="hidden md:flex gap-x-2 items-center">
|
||||||
|
<LinkButton
|
||||||
|
size="XS"
|
||||||
|
to="settings/keyboard"
|
||||||
|
theme="light"
|
||||||
|
text={selectedKeyboard.name}
|
||||||
|
LeadingIcon={LuKeyboard}
|
||||||
|
/>
|
||||||
|
<div className="h-[20px] w-px bg-slate-800/20 dark:bg-slate-200/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Hide"
|
text="Hide"
|
||||||
LeadingIcon={ChevronDownIcon}
|
LeadingIcon={ChevronDownIcon}
|
||||||
onClick={() => setVirtualKeyboard(false)}
|
onClick={() => setVirtualKeyboardEnabled(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -275,66 +285,61 @@ 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={layoutName}
|
layoutName={mainLayoutName}
|
||||||
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={{
|
layout={virtualKeyboard.main}
|
||||||
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}
|
||||||
syncInstanceInputs={true}
|
enableLayoutCandidates={false}
|
||||||
debug={false}
|
preventMouseDownDefault={true}
|
||||||
|
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={layoutName}
|
layoutName="default"
|
||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
|
onKeyReleased={onKeyUp}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={virtualKeyboard.control}
|
||||||
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
disableButtonHold={true}
|
||||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
enableLayoutCandidates={false}
|
||||||
}}
|
preventMouseDownDefault={true}
|
||||||
syncInstanceInputs={true}
|
preventMouseUpDefault={true}
|
||||||
debug={false}
|
stopMouseDownPropagation={true}
|
||||||
|
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={{
|
layout={virtualKeyboard.arrows}
|
||||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
disableButtonHold={true}
|
||||||
}}
|
enableLayoutCandidates={false}
|
||||||
syncInstanceInputs={true}
|
preventMouseDownDefault={true}
|
||||||
debug={false}
|
preventMouseUpDefault={true}
|
||||||
|
stopMouseDownPropagation={true}
|
||||||
|
stopMouseUpPropagation={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* TODO add optional number pad */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,14 @@ 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, modifiers } from "@/keyboardMappings";
|
import { keys } 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,
|
||||||
|
|
@ -53,15 +51,22 @@ 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 = useRTCStore(state => state.mediaStream);
|
const { mediaStream, peerConnectionState } = useRTCStore();
|
||||||
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 { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
||||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
const {
|
||||||
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
getRelMouseMoveHandler,
|
||||||
|
getAbsMouseMoveHandler,
|
||||||
|
getMouseWheelHandler,
|
||||||
|
resetMousePosition,
|
||||||
|
} = useMouse();
|
||||||
const {
|
const {
|
||||||
setClientSize: setVideoClientSize,
|
setClientSize: setVideoClientSize,
|
||||||
setSize: setVideoSize,
|
setSize: setVideoSize,
|
||||||
|
|
@ -69,49 +74,33 @@ 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 = useSettingsStore(state => state.videoSaturation);
|
const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore();
|
||||||
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(state => state.peerConnection);
|
const { peerConnection } = useRTCStore();
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
|
||||||
|
|
||||||
// Misc states and hooks
|
|
||||||
const { send } = useJsonRpc();
|
|
||||||
|
|
||||||
// Video-related
|
// Video-related
|
||||||
|
const handleResize = useCallback(
|
||||||
|
({ width, height }: { width: number | undefined; height: number | undefined }) => {
|
||||||
|
if (!videoElm.current) return;
|
||||||
|
// Do something with width and height, e.g.:
|
||||||
|
setVideoClientSize(width || 0, height || 0);
|
||||||
|
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
|
||||||
|
},
|
||||||
|
[setVideoClientSize, setVideoSize]
|
||||||
|
);
|
||||||
|
|
||||||
useResizeObserver({
|
useResizeObserver({
|
||||||
ref: videoElm as React.RefObject<HTMLElement>,
|
ref: videoElm as React.RefObject<HTMLElement>,
|
||||||
onResize: ({ width, height }) => {
|
onResize: handleResize,
|
||||||
// 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(
|
||||||
|
|
@ -132,15 +121,14 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
|
||||||
function updateVideoSizeOnMount() {
|
function updateVideoSizeOnMount() {
|
||||||
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||||
},
|
},
|
||||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
[updateVideoSizeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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.permissions || !navigator.permissions.query) {
|
if (!navigator || !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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,28 +163,30 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
|
||||||
|
|
||||||
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
||||||
|
|
||||||
if (isKeyboardLockGranted && "keyboard" in navigator) {
|
if (isKeyboardLockGranted && navigator && "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]);
|
}, [checkNavigatorPermissions, setIsKeyboardLockActive]);
|
||||||
|
|
||||||
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 ("keyboard" in navigator) {
|
if (navigator && "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;
|
||||||
|
|
@ -249,273 +239,78 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
}, [releaseKeyboardLock]);
|
}, [releaseKeyboardLock]);
|
||||||
|
|
||||||
// Mouse-related
|
const absMouseMoveHandler = useMemo(
|
||||||
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
() => getAbsMouseMoveHandler({
|
||||||
|
videoClientWidth,
|
||||||
const sendRelMouseMovement = useCallback(
|
videoClientHeight,
|
||||||
(x: number, y: number, buttons: number) => {
|
videoWidth,
|
||||||
if (settings.mouseMode !== "relative") return;
|
videoHeight,
|
||||||
// if we ignore the event, double-click will not work
|
}),
|
||||||
// if (x === 0 && y === 0 && buttons === 0) return;
|
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight],
|
||||||
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
|
||||||
setMouseMove({ x, y, buttons });
|
|
||||||
},
|
|
||||||
[send, setMouseMove, settings.mouseMode],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const relMouseMoveHandler = useCallback(
|
const relMouseMoveHandler = useMemo(
|
||||||
(e: MouseEvent) => {
|
() => getRelMouseMoveHandler(),
|
||||||
if (settings.mouseMode !== "relative") return;
|
[getRelMouseMoveHandler],
|
||||||
if (isPointerLockActive === false && isPointerLockPossible) return;
|
|
||||||
|
|
||||||
// Send mouse movement
|
|
||||||
const { buttons } = e;
|
|
||||||
sendRelMouseMovement(e.movementX, e.movementY, buttons);
|
|
||||||
},
|
|
||||||
[isPointerLockActive, isPointerLockPossible, sendRelMouseMovement, settings.mouseMode],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendAbsMouseMovement = useCallback(
|
const mouseWheelHandler = useMemo(
|
||||||
(x: number, y: number, buttons: number) => {
|
() => getMouseWheelHandler(),
|
||||||
if (settings.mouseMode !== "absolute") return;
|
[getMouseWheelHandler],
|
||||||
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(
|
||||||
async (e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const code = getAdjustedKeyCode(e);
|
||||||
let code = e.code;
|
const hidKey = keys[code];
|
||||||
const key = e.key;
|
|
||||||
|
|
||||||
if (!isKeyboardLedManagedByHost) {
|
if (hidKey === undefined) {
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
console.warn(`Key down not mapped: ${code}`);
|
||||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
return;
|
||||||
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) {
|
if (e.metaKey && hidKey < 0xE0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const prev = useHidStore.getState();
|
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
|
||||||
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
|
handleKeyPress(hidKey, false);
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
|
console.debug(`Key down: ${hidKey}`);
|
||||||
|
handleKeyPress(hidKey, true);
|
||||||
|
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
||||||
|
// If the left meta key was just pressed and we're not keyboard locked
|
||||||
|
// we'll never see the keyup event because the browser is going to lose
|
||||||
|
// focus so set a deferred keyup after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
console.debug(`Forcing the left meta key release`);
|
||||||
|
handleKeyPress(hidKey, false);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[handleKeyPress, isKeyboardLockActive],
|
||||||
handleModifierKeys,
|
|
||||||
sendKeyboardEvent,
|
|
||||||
isKeyboardLedManagedByHost,
|
|
||||||
setIsNumLockActive,
|
|
||||||
setIsCapsLockActive,
|
|
||||||
setIsScrollLockActive,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyUpHandler = useCallback(
|
const keyUpHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
async (e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const code = getAdjustedKeyCode(e);
|
||||||
|
const hidKey = keys[code];
|
||||||
|
|
||||||
if (!isKeyboardLedManagedByHost) {
|
if (hidKey === undefined) {
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
console.warn(`Key up not mapped: ${code}`);
|
||||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
return;
|
||||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtering out the key that was just released (keys[e.code])
|
console.debug(`Key up: ${hidKey}`);
|
||||||
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
|
handleKeyPress(hidKey, false);
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|
@ -526,7 +321,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.log("Force playing video");
|
console.debug("Force playing video");
|
||||||
videoElm.current.play();
|
videoElm.current.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -569,13 +364,7 @@ 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
|
||||||
|
|
@ -624,14 +413,16 @@ 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", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
|
videoElmRefValue.addEventListener("mousemove", mouseHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
|
videoElmRefValue.addEventListener("pointerdown", mouseHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal });
|
videoElmRefValue.addEventListener("pointerup", mouseHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
signal,
|
signal,
|
||||||
passive: true,
|
passive: true,
|
||||||
|
|
@ -659,7 +450,16 @@ 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);
|
||||||
|
|
@ -692,6 +492,18 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function SerialConsole() {
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
const { setTerminalType } = useUiStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,27 @@
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||||
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
import { 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-dom";
|
import { useLocation } from "react-router";
|
||||||
|
|
||||||
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, useRTCStore } from "@/hooks/stores";
|
import { RemoteVirtualMediaState, useMountMediaStore } 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) {
|
||||||
|
|
@ -94,42 +70,6 @@ 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="">
|
||||||
|
|
@ -202,17 +142,6 @@ 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={{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, 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,7 +10,8 @@ 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, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
|
import { KeyStroke } 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[]) => {
|
||||||
|
|
@ -18,33 +19,24 @@ 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 setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
const { setPasteModeEnabled } = useHidStore();
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const { setDisableVideoFocusTrap } = useUiStore();
|
||||||
|
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
|
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const { setKeyboardLayout } = useSettingsStore();
|
||||||
const setKeyboardLayout = useSettingsStore(
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
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) => {
|
||||||
|
|
@ -54,24 +46,23 @@ export default function PasteModal() {
|
||||||
}, [send, setKeyboardLayout]);
|
}, [send, setKeyboardLayout]);
|
||||||
|
|
||||||
const onCancelPasteMode = useCallback(() => {
|
const onCancelPasteMode = useCallback(() => {
|
||||||
setPasteMode(false);
|
setPasteModeEnabled(false);
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
setInvalidChars([]);
|
setInvalidChars([]);
|
||||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
}, [setDisableVideoFocusTrap, setPasteModeEnabled]);
|
||||||
|
|
||||||
const onConfirmPaste = useCallback(async () => {
|
const onConfirmPaste = useCallback(async () => {
|
||||||
setPasteMode(false);
|
setPasteModeEnabled(false);
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
|
|
||||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||||
const keyboard: KeyboardLayout = selectedKeyboard(safeKeyboardLayout);
|
if (!selectedKeyboard) return;
|
||||||
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 = keyboard.chars[char];
|
const keyprops = selectedKeyboard.chars[char];
|
||||||
if (!keyprops) continue;
|
if (!keyprops) continue;
|
||||||
|
|
||||||
const { key, shift, altRight, deadKey, accentKey } = keyprops;
|
const { key, shift, altRight, deadKey, accentKey } = keyprops;
|
||||||
|
|
@ -111,7 +102,7 @@ export default function PasteModal() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]);
|
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (TextAreaRef.current) {
|
if (TextAreaRef.current) {
|
||||||
|
|
@ -161,7 +152,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(safeKeyboardLayout).chars[char]),
|
.filter(char => !selectedKeyboard.chars[char]),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -182,7 +173,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(safeKeyboardLayout).name}
|
Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,8 @@ 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(state => state.setDisableVideoFocusTrap);
|
const { setDisableVideoFocusTrap } = useUiStore();
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,18 @@ function createChartArray<T, K extends keyof T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionStatsSidebar() {
|
export default function ConnectionStatsSidebar() {
|
||||||
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
|
const { sidebarView, setSidebarView } = useUiStore();
|
||||||
|
const {
|
||||||
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
|
mediaStream,
|
||||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
peerConnection,
|
||||||
|
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>,
|
||||||
|
|
@ -49,20 +57,6 @@ 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;
|
||||||
|
|
@ -80,8 +74,7 @@ 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) {
|
||||||
|
|
|
||||||