mirror of https://github.com/jetkvm/kvm.git
Merge 2e4a49feb6 into 2444817455
This commit is contained in:
commit
ec0d5d7cb7
4
Makefile
4
Makefile
|
|
@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BUILDDATE := $(shell date -u +%FT%T%z)
|
BUILDDATE := $(shell date -u +%FT%T%z)
|
||||||
BUILDTS := $(shell date -u +%s)
|
BUILDTS := $(shell date -u +%s)
|
||||||
REVISION := $(shell git rev-parse HEAD)
|
REVISION := $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
VERSION_DEV ?= 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
||||||
VERSION := 0.4.8
|
VERSION ?= 0.4.8
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
|
|
|
||||||
97
cloud.go
97
cloud.go
|
|
@ -197,6 +197,20 @@ func wsResetMetrics(established bool, sourceType string, source string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCloudRegister(c *gin.Context) {
|
func handleCloudRegister(c *gin.Context) {
|
||||||
|
sessionID, _ := c.Cookie("sessionId")
|
||||||
|
authToken, _ := c.Cookie("authToken")
|
||||||
|
|
||||||
|
if sessionID != "" && authToken != "" && authToken == config.LocalAuthToken {
|
||||||
|
session := sessionManager.GetSession(sessionID)
|
||||||
|
if session != nil && !session.HasPermission(PermissionSettingsWrite) {
|
||||||
|
c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if sessionID != "" {
|
||||||
|
c.JSON(401, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req CloudRegisterRequest
|
var req CloudRegisterRequest
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -426,8 +440,15 @@ func handleSessionRequest(
|
||||||
req WebRTCSessionRequest,
|
req WebRTCSessionRequest,
|
||||||
isCloudConnection bool,
|
isCloudConnection bool,
|
||||||
source string,
|
source string,
|
||||||
|
connectionID string,
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) (returnErr error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
websocketLogger.Error().Interface("panic", r).Msg("PANIC in handleSessionRequest")
|
||||||
|
returnErr = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
var sourceType string
|
var sourceType string
|
||||||
if isCloudConnection {
|
if isCloudConnection {
|
||||||
sourceType = "cloud"
|
sourceType = "cloud"
|
||||||
|
|
@ -453,6 +474,7 @@ func handleSessionRequest(
|
||||||
IsCloud: isCloudConnection,
|
IsCloud: isCloudConnection,
|
||||||
LocalIP: req.IP,
|
LocalIP: req.IP,
|
||||||
ICEServers: req.ICEServers,
|
ICEServers: req.ICEServers,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
Logger: scopedLogger,
|
Logger: scopedLogger,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -462,26 +484,73 @@ func handleSessionRequest(
|
||||||
|
|
||||||
sd, err := session.ExchangeOffer(req.Sd)
|
sd, err := session.ExchangeOffer(req.Sd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to exchange offer")
|
||||||
_ = 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 {
|
session.Source = source
|
||||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
|
||||||
peerConn := currentSession.peerConnection
|
if isCloudConnection && req.OidcGoogle != "" {
|
||||||
go func() {
|
session.Identity = config.GoogleIdentity
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
_ = peerConn.Close()
|
// Use client-provided sessionId for reconnection, otherwise generate new one
|
||||||
}()
|
// This enables multi-tab support while preserving reconnection on refresh
|
||||||
|
if req.SessionId != "" {
|
||||||
|
session.ID = req.SessionId
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID")
|
||||||
|
} else {
|
||||||
|
session.ID = connectionID
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.ID = connectionID
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established")
|
||||||
}
|
}
|
||||||
|
|
||||||
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
if sessionManager == nil {
|
||||||
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
scopedLogger.Error().Msg("sessionManager is nil")
|
||||||
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
|
||||||
|
return fmt.Errorf("session manager not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel any ongoing keyboard macro when session changes
|
err = sessionManager.AddSession(session, req.SessionSettings)
|
||||||
cancelKeyboardMacro()
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
|
||||||
|
if err == ErrMaxSessionsReached {
|
||||||
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": "maximum sessions reached"})
|
||||||
|
} else {
|
||||||
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
currentSession = session
|
if session.HasPermission(PermissionPaste) {
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
cancelKeyboardMacro()
|
||||||
|
}
|
||||||
|
|
||||||
|
requireNickname := false
|
||||||
|
requireApproval := false
|
||||||
|
if currentSessionSettings != nil {
|
||||||
|
requireNickname = currentSessionSettings.RequireNickname
|
||||||
|
requireApproval = currentSessionSettings.RequireApproval
|
||||||
|
}
|
||||||
|
|
||||||
|
err = wsjson.Write(context.Background(), c, gin.H{
|
||||||
|
"type": "answer",
|
||||||
|
"data": sd,
|
||||||
|
"sessionId": session.ID,
|
||||||
|
"mode": session.Mode,
|
||||||
|
"nickname": session.Nickname,
|
||||||
|
"requireNickname": requireNickname,
|
||||||
|
"requireApproval": requireApproval,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.flushCandidates != nil {
|
||||||
|
session.flushCandidates()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
config.go
43
config.go
|
|
@ -78,11 +78,21 @@ func (m *KeyboardMacro) Validate() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MultiSessionConfig defines settings for multi-session support
|
||||||
|
type MultiSessionConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MaxSessions int `json:"max_sessions"`
|
||||||
|
PrimaryTimeout int `json:"primary_timeout_seconds"`
|
||||||
|
AllowCloudOverride bool `json:"allow_cloud_override"`
|
||||||
|
RequireAuthTransfer bool `json:"require_auth_transfer"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CloudURL string `json:"cloud_url"`
|
CloudURL string `json:"cloud_url"`
|
||||||
CloudAppURL string `json:"cloud_app_url"`
|
CloudAppURL string `json:"cloud_app_url"`
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudToken string `json:"cloud_token"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
|
MultiSession *MultiSessionConfig `json:"multi_session"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
|
|
@ -105,6 +115,7 @@ type Config struct {
|
||||||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||||
NetworkConfig *types.NetworkConfig `json:"network_config"`
|
NetworkConfig *types.NetworkConfig `json:"network_config"`
|
||||||
DefaultLogLevel string `json:"default_log_level"`
|
DefaultLogLevel string `json:"default_log_level"`
|
||||||
|
SessionSettings *SessionSettings `json:"session_settings"`
|
||||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||||
VideoQualityFactor float64 `json:"video_quality_factor"`
|
VideoQualityFactor float64 `json:"video_quality_factor"`
|
||||||
}
|
}
|
||||||
|
|
@ -156,17 +167,31 @@ var (
|
||||||
|
|
||||||
func getDefaultConfig() Config {
|
func getDefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
CloudURL: "https://api.jetkvm.com",
|
CloudURL: "https://api.jetkvm.com",
|
||||||
CloudAppURL: "https://app.jetkvm.com",
|
CloudAppURL: "https://app.jetkvm.com",
|
||||||
AutoUpdateEnabled: true, // Set a default value
|
AutoUpdateEnabled: true, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
|
MultiSession: &MultiSessionConfig{
|
||||||
|
Enabled: true, // Enable by default for new features
|
||||||
|
MaxSessions: 10, // Reasonable default
|
||||||
|
PrimaryTimeout: 300, // 5 minutes
|
||||||
|
AllowCloudOverride: true, // Cloud sessions can take control
|
||||||
|
RequireAuthTransfer: false, // Don't require auth by default
|
||||||
|
},
|
||||||
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
|
||||||
JigglerEnabled: false,
|
SessionSettings: &SessionSettings{
|
||||||
|
RequireApproval: false,
|
||||||
|
RequireNickname: false,
|
||||||
|
ReconnectGrace: 10,
|
||||||
|
PrivateKeystrokes: false,
|
||||||
|
MaxRejectionAttempts: 3,
|
||||||
|
},
|
||||||
|
JigglerEnabled: false,
|
||||||
// This is the "Standard" jiggler option in the UI
|
// This is the "Standard" jiggler option in the UI
|
||||||
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
|
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
|
||||||
TLSMode: "",
|
TLSMode: "",
|
||||||
|
|
@ -248,6 +273,14 @@ func LoadConfig() {
|
||||||
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if loadedConfig.MultiSession == nil {
|
||||||
|
loadedConfig.MultiSession = getDefaultConfig().MultiSession
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedConfig.SessionSettings == nil {
|
||||||
|
loadedConfig.SessionSettings = getDefaultConfig().SessionSettings
|
||||||
|
}
|
||||||
|
|
||||||
// fixup old keyboard layout value
|
// fixup old keyboard layout value
|
||||||
if loadedConfig.KeyboardLayout == "en_US" {
|
if loadedConfig.KeyboardLayout == "en_US" {
|
||||||
loadedConfig.KeyboardLayout = "en-US"
|
loadedConfig.KeyboardLayout = "en-US"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import "github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
func handlePermissionDeniedChannel(d *webrtc.DataChannel, message string) {
|
||||||
|
d.OnOpen(func() {
|
||||||
|
_ = d.SendText(message + "\r\n")
|
||||||
|
d.Close()
|
||||||
|
})
|
||||||
|
d.OnMessage(func(msg webrtc.DataChannelMessage) {})
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ func updateDisplay() {
|
||||||
nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected")
|
nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected")
|
||||||
_, _ = nativeInstance.UIObjClearState("hdmi_status_label", "LV_STATE_CHECKED")
|
_, _ = nativeInstance.UIObjClearState("hdmi_status_label", "LV_STATE_CHECKED")
|
||||||
}
|
}
|
||||||
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
|
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", getActiveSessions()))
|
||||||
|
|
||||||
if networkManager != nil && networkManager.IsUp() {
|
if networkManager != nil && networkManager.IsUp() {
|
||||||
nativeInstance.UISetVar("main_screen", "home_screen")
|
nativeInstance.UISetVar("main_screen", "home_screen")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPermissionDeniedKeyboard = errors.New("permission denied: keyboard input")
|
||||||
|
ErrPermissionDeniedMouse = errors.New("permission denied: mouse input")
|
||||||
|
ErrNotPrimarySession = errors.New("operation requires primary session")
|
||||||
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
|
)
|
||||||
44
hidrpc.go
44
hidrpc.go
|
|
@ -16,6 +16,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
|
|
||||||
switch message.Type() {
|
switch message.Type() {
|
||||||
case hidrpc.TypeHandshake:
|
case hidrpc.TypeHandshake:
|
||||||
|
if !session.HasPermission(PermissionVideoView) {
|
||||||
|
logger.Debug().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("mode", string(session.Mode)).
|
||||||
|
Msg("handshake blocked: session lacks PermissionVideoView")
|
||||||
|
return
|
||||||
|
}
|
||||||
message, err := hidrpc.NewHandshakeMessage().Marshal()
|
message, err := hidrpc.NewHandshakeMessage().Marshal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to marshal handshake message")
|
logger.Warn().Err(err).Msg("failed to marshal handshake message")
|
||||||
|
|
@ -27,8 +34,18 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
}
|
}
|
||||||
session.hidRPCAvailable = true
|
session.hidRPCAvailable = true
|
||||||
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
|
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
|
||||||
|
if !session.HasPermission(PermissionKeyboardInput) {
|
||||||
|
logger.Debug().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("mode", string(session.Mode)).
|
||||||
|
Msg("keyboard input blocked: session lacks PermissionKeyboardInput")
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcErr = handleHidRPCKeyboardInput(message)
|
rpcErr = handleHidRPCKeyboardInput(message)
|
||||||
case hidrpc.TypeKeyboardMacroReport:
|
case hidrpc.TypeKeyboardMacroReport:
|
||||||
|
if !session.HasPermission(PermissionPaste) {
|
||||||
|
return
|
||||||
|
}
|
||||||
keyboardMacroReport, err := message.KeyboardMacroReport()
|
keyboardMacroReport, err := message.KeyboardMacroReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
||||||
|
|
@ -36,11 +53,24 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
}
|
}
|
||||||
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
|
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
|
||||||
case hidrpc.TypeCancelKeyboardMacroReport:
|
case hidrpc.TypeCancelKeyboardMacroReport:
|
||||||
|
if !session.HasPermission(PermissionPaste) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcCancelKeyboardMacro()
|
rpcCancelKeyboardMacro()
|
||||||
return
|
return
|
||||||
case hidrpc.TypeKeypressKeepAliveReport:
|
case hidrpc.TypeKeypressKeepAliveReport:
|
||||||
|
if !session.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
||||||
case hidrpc.TypePointerReport:
|
case hidrpc.TypePointerReport:
|
||||||
|
if !session.HasPermission(PermissionMouseInput) {
|
||||||
|
logger.Debug().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("mode", string(session.Mode)).
|
||||||
|
Msg("pointer report blocked: session lacks PermissionMouseInput")
|
||||||
|
return
|
||||||
|
}
|
||||||
pointerReport, err := message.PointerReport()
|
pointerReport, err := message.PointerReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get pointer report")
|
logger.Warn().Err(err).Msg("failed to get pointer report")
|
||||||
|
|
@ -48,6 +78,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
}
|
}
|
||||||
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
|
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
|
||||||
case hidrpc.TypeMouseReport:
|
case hidrpc.TypeMouseReport:
|
||||||
|
if !session.HasPermission(PermissionMouseInput) {
|
||||||
|
logger.Debug().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("mode", string(session.Mode)).
|
||||||
|
Msg("mouse report blocked: session lacks PermissionMouseInput")
|
||||||
|
return
|
||||||
|
}
|
||||||
mouseReport, err := message.MouseReport()
|
mouseReport, err := message.MouseReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get mouse report")
|
logger.Warn().Err(err).Msg("failed to get mouse report")
|
||||||
|
|
@ -116,14 +153,15 @@ const baseExtension = expectedRate + maxLateness // 100ms extension on perfect t
|
||||||
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
|
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
|
||||||
|
|
||||||
func handleHidRPCKeypressKeepAlive(session *Session) error {
|
func handleHidRPCKeypressKeepAlive(session *Session) error {
|
||||||
|
// NOTE: Do NOT update LastActive here - jiggler keep-alives are automated,
|
||||||
|
// not human input. Only actual keyboard/mouse input should prevent timeout.
|
||||||
|
|
||||||
session.keepAliveJitterLock.Lock()
|
session.keepAliveJitterLock.Lock()
|
||||||
defer session.keepAliveJitterLock.Unlock()
|
defer session.keepAliveJitterLock.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
|
// Staleness guard: discard ancient packets after network stall/machine sleep
|
||||||
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
|
|
||||||
// This prevents “zombie” keepalives from reviving a key that should already be released.
|
|
||||||
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
|
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Permission represents a specific action that can be performed
|
||||||
|
type Permission string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Video/Display permissions
|
||||||
|
PermissionVideoView Permission = "video.view"
|
||||||
|
|
||||||
|
// Input permissions
|
||||||
|
PermissionKeyboardInput Permission = "keyboard.input"
|
||||||
|
PermissionMouseInput Permission = "mouse.input"
|
||||||
|
PermissionPaste Permission = "clipboard.paste"
|
||||||
|
|
||||||
|
// Session management permissions
|
||||||
|
PermissionSessionTransfer Permission = "session.transfer"
|
||||||
|
PermissionSessionApprove Permission = "session.approve"
|
||||||
|
PermissionSessionKick Permission = "session.kick"
|
||||||
|
PermissionSessionRequestPrimary Permission = "session.request_primary"
|
||||||
|
PermissionSessionReleasePrimary Permission = "session.release_primary"
|
||||||
|
PermissionSessionManage Permission = "session.manage"
|
||||||
|
|
||||||
|
// Power/USB control permissions
|
||||||
|
PermissionPowerControl Permission = "power.control"
|
||||||
|
PermissionUSBControl Permission = "usb.control"
|
||||||
|
|
||||||
|
// Mount/Media permissions
|
||||||
|
PermissionMountMedia Permission = "mount.media"
|
||||||
|
PermissionUnmountMedia Permission = "mount.unmedia"
|
||||||
|
PermissionMountList Permission = "mount.list"
|
||||||
|
|
||||||
|
// Extension permissions
|
||||||
|
PermissionExtensionManage Permission = "extension.manage"
|
||||||
|
|
||||||
|
// Terminal/Serial permissions
|
||||||
|
PermissionTerminalAccess Permission = "terminal.access"
|
||||||
|
PermissionSerialAccess Permission = "serial.access"
|
||||||
|
PermissionExtensionATX Permission = "extension.atx"
|
||||||
|
PermissionExtensionDC Permission = "extension.dc"
|
||||||
|
PermissionExtensionSerial Permission = "extension.serial"
|
||||||
|
PermissionExtensionWOL Permission = "extension.wol"
|
||||||
|
|
||||||
|
// Settings permissions
|
||||||
|
PermissionSettingsRead Permission = "settings.read"
|
||||||
|
PermissionSettingsWrite Permission = "settings.write"
|
||||||
|
PermissionSettingsAccess Permission = "settings.access" // Access control settings
|
||||||
|
|
||||||
|
// System permissions
|
||||||
|
PermissionSystemReboot Permission = "system.reboot"
|
||||||
|
PermissionSystemUpdate Permission = "system.update"
|
||||||
|
PermissionSystemNetwork Permission = "system.network"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionSet represents a set of permissions
|
||||||
|
type PermissionSet map[Permission]bool
|
||||||
|
|
||||||
|
// RolePermissions defines permissions for each session mode
|
||||||
|
var RolePermissions = map[SessionMode]PermissionSet{
|
||||||
|
SessionModePrimary: {
|
||||||
|
// Primary has all permissions
|
||||||
|
PermissionVideoView: true,
|
||||||
|
PermissionKeyboardInput: true,
|
||||||
|
PermissionMouseInput: true,
|
||||||
|
PermissionPaste: true,
|
||||||
|
PermissionSessionTransfer: true,
|
||||||
|
PermissionSessionApprove: true,
|
||||||
|
PermissionSessionKick: true,
|
||||||
|
PermissionSessionReleasePrimary: true,
|
||||||
|
PermissionMountMedia: true,
|
||||||
|
PermissionUnmountMedia: true,
|
||||||
|
PermissionMountList: true,
|
||||||
|
PermissionExtensionManage: true,
|
||||||
|
PermissionExtensionATX: true,
|
||||||
|
PermissionExtensionDC: true,
|
||||||
|
PermissionExtensionSerial: true,
|
||||||
|
PermissionExtensionWOL: true,
|
||||||
|
PermissionSettingsRead: true,
|
||||||
|
PermissionSettingsWrite: true,
|
||||||
|
PermissionSettingsAccess: true, // Only primary can access settings UI
|
||||||
|
PermissionSystemReboot: true,
|
||||||
|
PermissionSystemUpdate: true,
|
||||||
|
PermissionSystemNetwork: true,
|
||||||
|
PermissionTerminalAccess: true,
|
||||||
|
PermissionSerialAccess: true,
|
||||||
|
PermissionPowerControl: true,
|
||||||
|
PermissionUSBControl: true,
|
||||||
|
PermissionSessionManage: true,
|
||||||
|
PermissionSessionRequestPrimary: false, // Primary doesn't need to request
|
||||||
|
},
|
||||||
|
SessionModeObserver: {
|
||||||
|
// Observers can only view
|
||||||
|
PermissionVideoView: true,
|
||||||
|
PermissionSessionRequestPrimary: true,
|
||||||
|
PermissionMountList: true, // Can see what's mounted but not mount/unmount
|
||||||
|
},
|
||||||
|
SessionModeQueued: {
|
||||||
|
// Queued sessions can view and request primary
|
||||||
|
PermissionVideoView: true,
|
||||||
|
PermissionSessionRequestPrimary: true,
|
||||||
|
},
|
||||||
|
SessionModePending: {
|
||||||
|
// Pending sessions have NO permissions until approved
|
||||||
|
// This prevents unauthorized video access
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPermission checks if a session mode has a specific permission
|
||||||
|
func CheckPermission(mode SessionMode, perm Permission) bool {
|
||||||
|
permissions, exists := RolePermissions[mode]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return permissions[perm]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionsForMode returns all permissions for a session mode
|
||||||
|
func GetPermissionsForMode(mode SessionMode) PermissionSet {
|
||||||
|
permissions, exists := RolePermissions[mode]
|
||||||
|
if !exists {
|
||||||
|
return PermissionSet{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy to prevent modification
|
||||||
|
result := make(PermissionSet)
|
||||||
|
for k, v := range permissions {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePermissionForMode is a middleware-like function for RPC handlers
|
||||||
|
func RequirePermissionForMode(mode SessionMode, perm Permission) error {
|
||||||
|
if !CheckPermission(mode, perm) {
|
||||||
|
return fmt.Errorf("permission denied: %s", perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionsResponse is the response structure for getPermissions RPC
|
||||||
|
type GetPermissionsResponse struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Permissions map[string]bool `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodPermissions maps RPC methods to required permissions
|
||||||
|
var MethodPermissions = map[string]Permission{
|
||||||
|
// Power/hardware control
|
||||||
|
"setATXPowerAction": PermissionPowerControl,
|
||||||
|
"setDCPowerState": PermissionPowerControl,
|
||||||
|
"setDCRestoreState": PermissionPowerControl,
|
||||||
|
|
||||||
|
// USB device control
|
||||||
|
"setUsbDeviceState": PermissionUSBControl,
|
||||||
|
"setUsbDevices": PermissionUSBControl,
|
||||||
|
|
||||||
|
// Mount operations
|
||||||
|
"mountUsb": PermissionMountMedia,
|
||||||
|
"unmountUsb": PermissionMountMedia,
|
||||||
|
"mountBuiltInImage": PermissionMountMedia,
|
||||||
|
"rpcMountBuiltInImage": PermissionMountMedia,
|
||||||
|
"unmountImage": PermissionMountMedia,
|
||||||
|
"mountWithHTTP": PermissionMountMedia,
|
||||||
|
"mountWithStorage": PermissionMountMedia,
|
||||||
|
"checkMountUrl": PermissionMountMedia,
|
||||||
|
"startStorageFileUpload": PermissionMountMedia,
|
||||||
|
"deleteStorageFile": PermissionMountMedia,
|
||||||
|
|
||||||
|
// Settings operations
|
||||||
|
"setDevModeState": PermissionSettingsWrite,
|
||||||
|
"setDevChannelState": PermissionSettingsWrite,
|
||||||
|
"setAutoUpdateState": PermissionSettingsWrite,
|
||||||
|
"tryUpdate": PermissionSettingsWrite,
|
||||||
|
"reboot": PermissionSettingsWrite,
|
||||||
|
"resetConfig": PermissionSettingsWrite,
|
||||||
|
"setNetworkSettings": PermissionSettingsWrite,
|
||||||
|
"setLocalLoopbackOnly": PermissionSettingsWrite,
|
||||||
|
"renewDHCPLease": PermissionSettingsWrite,
|
||||||
|
"setSSHKeyState": PermissionSettingsWrite,
|
||||||
|
"setTLSState": PermissionSettingsWrite,
|
||||||
|
"setVideoBandwidth": PermissionSettingsWrite,
|
||||||
|
"setVideoFramerate": PermissionSettingsWrite,
|
||||||
|
"setVideoResolution": PermissionSettingsWrite,
|
||||||
|
"setVideoEncoderQuality": PermissionSettingsWrite,
|
||||||
|
"setVideoSignal": PermissionSettingsWrite,
|
||||||
|
"setSerialBitrate": PermissionSettingsWrite,
|
||||||
|
"setSerialSettings": PermissionSettingsWrite,
|
||||||
|
"setSessionSettings": PermissionSessionManage,
|
||||||
|
"updateSessionSettings": PermissionSessionManage,
|
||||||
|
|
||||||
|
// Display settings
|
||||||
|
"setEDID": PermissionSettingsWrite,
|
||||||
|
"setStreamQualityFactor": PermissionSettingsWrite,
|
||||||
|
"setDisplayRotation": PermissionSettingsWrite,
|
||||||
|
"setBacklightSettings": PermissionSettingsWrite,
|
||||||
|
|
||||||
|
// USB/HID settings
|
||||||
|
"setUsbEmulationState": PermissionSettingsWrite,
|
||||||
|
"setUsbConfig": PermissionSettingsWrite,
|
||||||
|
"setKeyboardLayout": PermissionSettingsWrite,
|
||||||
|
"setJigglerState": PermissionSettingsWrite,
|
||||||
|
"setJigglerConfig": PermissionSettingsWrite,
|
||||||
|
"setMassStorageMode": PermissionSettingsWrite,
|
||||||
|
"setKeyboardMacros": PermissionSettingsWrite,
|
||||||
|
"setWakeOnLanDevices": PermissionSettingsWrite,
|
||||||
|
|
||||||
|
// Cloud settings
|
||||||
|
"setCloudUrl": PermissionSettingsWrite,
|
||||||
|
"deregisterDevice": PermissionSettingsWrite,
|
||||||
|
|
||||||
|
// Active extension control
|
||||||
|
"setActiveExtension": PermissionExtensionManage,
|
||||||
|
|
||||||
|
// Input operations (already handled in other places but for consistency)
|
||||||
|
"keyboardReport": PermissionKeyboardInput,
|
||||||
|
"keypressReport": PermissionKeyboardInput,
|
||||||
|
"absMouseReport": PermissionMouseInput,
|
||||||
|
"relMouseReport": PermissionMouseInput,
|
||||||
|
"wheelReport": PermissionMouseInput,
|
||||||
|
"executeKeyboardMacro": PermissionPaste,
|
||||||
|
"cancelKeyboardMacro": PermissionPaste,
|
||||||
|
|
||||||
|
// Session operations
|
||||||
|
"approveNewSession": PermissionSessionApprove,
|
||||||
|
"denyNewSession": PermissionSessionApprove,
|
||||||
|
"transferSession": PermissionSessionTransfer,
|
||||||
|
"transferPrimary": PermissionSessionTransfer,
|
||||||
|
"requestPrimary": PermissionSessionRequestPrimary,
|
||||||
|
"releasePrimary": PermissionSessionReleasePrimary,
|
||||||
|
|
||||||
|
// Extension operations
|
||||||
|
"activateExtension": PermissionExtensionManage,
|
||||||
|
"deactivateExtension": PermissionExtensionManage,
|
||||||
|
"sendWOLMagicPacket": PermissionExtensionWOL,
|
||||||
|
|
||||||
|
// Read operations - require appropriate read permissions
|
||||||
|
"getSessionSettings": PermissionSettingsRead,
|
||||||
|
"getSessionConfig": PermissionSettingsRead,
|
||||||
|
"getSessionData": PermissionVideoView,
|
||||||
|
"getNetworkSettings": PermissionSettingsRead,
|
||||||
|
"getSerialSettings": PermissionSettingsRead,
|
||||||
|
"getBacklightSettings": PermissionSettingsRead,
|
||||||
|
"getDisplayRotation": PermissionSettingsRead,
|
||||||
|
"getEDID": PermissionSettingsRead,
|
||||||
|
"get_edid": PermissionSettingsRead,
|
||||||
|
"getKeyboardLayout": PermissionSettingsRead,
|
||||||
|
"getJigglerConfig": PermissionSettingsRead,
|
||||||
|
"getJigglerState": PermissionSettingsRead,
|
||||||
|
"getStreamQualityFactor": PermissionSettingsRead,
|
||||||
|
"getVideoSettings": PermissionSettingsRead,
|
||||||
|
"getVideoBandwidth": PermissionSettingsRead,
|
||||||
|
"getVideoFramerate": PermissionSettingsRead,
|
||||||
|
"getVideoResolution": PermissionSettingsRead,
|
||||||
|
"getVideoEncoderQuality": PermissionSettingsRead,
|
||||||
|
"getVideoSignal": PermissionSettingsRead,
|
||||||
|
"getSerialBitrate": PermissionSettingsRead,
|
||||||
|
"getDevModeState": PermissionSettingsRead,
|
||||||
|
"getDevChannelState": PermissionSettingsRead,
|
||||||
|
"getAutoUpdateState": PermissionSettingsRead,
|
||||||
|
"getLocalLoopbackOnly": PermissionSettingsRead,
|
||||||
|
"getSSHKeyState": PermissionSettingsRead,
|
||||||
|
"getTLSState": PermissionSettingsRead,
|
||||||
|
"getCloudUrl": PermissionSettingsRead,
|
||||||
|
"getCloudState": PermissionSettingsRead,
|
||||||
|
"getNetworkState": PermissionSettingsRead,
|
||||||
|
|
||||||
|
// Mount/media read operations
|
||||||
|
"getMassStorageMode": PermissionMountList,
|
||||||
|
"getUsbState": PermissionMountList,
|
||||||
|
"getUSBState": PermissionMountList,
|
||||||
|
"listStorageFiles": PermissionMountList,
|
||||||
|
"getStorageSpace": PermissionMountList,
|
||||||
|
|
||||||
|
// Extension read operations
|
||||||
|
"getActiveExtension": PermissionSettingsRead,
|
||||||
|
|
||||||
|
// Power state reads
|
||||||
|
"getATXState": PermissionSettingsRead,
|
||||||
|
"getDCPowerState": PermissionSettingsRead,
|
||||||
|
"getDCRestoreState": PermissionSettingsRead,
|
||||||
|
|
||||||
|
// Device info reads (these should be accessible to all)
|
||||||
|
"getDeviceID": PermissionVideoView,
|
||||||
|
"getLocalVersion": PermissionVideoView,
|
||||||
|
"getVideoState": PermissionVideoView,
|
||||||
|
"getKeyboardLedState": PermissionVideoView,
|
||||||
|
"getKeyDownState": PermissionVideoView,
|
||||||
|
"ping": PermissionVideoView,
|
||||||
|
"getTimezones": PermissionVideoView,
|
||||||
|
"getSessions": PermissionVideoView,
|
||||||
|
"getUpdateStatus": PermissionSettingsRead,
|
||||||
|
"isUpdatePending": PermissionSettingsRead,
|
||||||
|
"getUsbEmulationState": PermissionSettingsRead,
|
||||||
|
"getUsbConfig": PermissionSettingsRead,
|
||||||
|
"getUsbDevices": PermissionSettingsRead,
|
||||||
|
"getKeyboardMacros": PermissionSettingsRead,
|
||||||
|
"getWakeOnLanDevices": PermissionSettingsRead,
|
||||||
|
"getVirtualMediaState": PermissionMountList,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMethodPermission returns the required permission for an RPC method
|
||||||
|
func GetMethodPermission(method string) (Permission, bool) {
|
||||||
|
perm, exists := MethodPermissions[method]
|
||||||
|
return perm, exists
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
// SessionMode represents the role/mode of a session
|
||||||
|
type SessionMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionModePrimary SessionMode = "primary"
|
||||||
|
SessionModeObserver SessionMode = "observer"
|
||||||
|
SessionModeQueued SessionMode = "queued"
|
||||||
|
SessionModePending SessionMode = "pending"
|
||||||
|
)
|
||||||
248
jsonrpc.go
248
jsonrpc.go
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -23,6 +24,40 @@ import (
|
||||||
"github.com/jetkvm/kvm/internal/utils"
|
"github.com/jetkvm/kvm/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nicknameRegex defines the valid pattern for nicknames (matching frontend validation)
|
||||||
|
var nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.@]+$`)
|
||||||
|
|
||||||
|
// isValidNickname checks if a nickname contains only valid characters
|
||||||
|
func isValidNickname(nickname string) bool {
|
||||||
|
return nicknameRegex.MatchString(nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global RPC rate limiting (protects against coordinated DoS from multiple sessions)
|
||||||
|
var (
|
||||||
|
globalRPCRateLimitMu sync.Mutex
|
||||||
|
globalRPCRateLimit int
|
||||||
|
globalRPCRateLimitWin time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkGlobalRPCRateLimit() bool {
|
||||||
|
const (
|
||||||
|
maxGlobalRPCPerSecond = 2000
|
||||||
|
rateLimitWindow = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
globalRPCRateLimitMu.Lock()
|
||||||
|
defer globalRPCRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(globalRPCRateLimitWin) > rateLimitWindow {
|
||||||
|
globalRPCRateLimit = 0
|
||||||
|
globalRPCRateLimitWin = now
|
||||||
|
}
|
||||||
|
|
||||||
|
globalRPCRateLimit++
|
||||||
|
return globalRPCRateLimit <= maxGlobalRPCPerSecond
|
||||||
|
}
|
||||||
|
|
||||||
type JSONRPCRequest struct {
|
type JSONRPCRequest struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
|
@ -54,11 +89,16 @@ type BacklightSettings struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
|
if session == nil || session.RPCChannel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
responseBytes, err := json.Marshal(response)
|
responseBytes, err := json.Marshal(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(string(responseBytes))
|
err = session.RPCChannel.SendText(string(responseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
||||||
|
|
@ -67,6 +107,11 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCEvent(event string, params any, session *Session) {
|
func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
|
// Defensive checks: skip if session or RPC channel is not ready
|
||||||
|
if session == nil || session.RPCChannel == nil {
|
||||||
|
return // Channel not ready or already closed - this is expected during cleanup
|
||||||
|
}
|
||||||
|
|
||||||
request := JSONRPCEvent{
|
request := JSONRPCEvent{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Method: event,
|
Method: event,
|
||||||
|
|
@ -77,10 +122,6 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if session == nil || session.RPCChannel == nil {
|
|
||||||
jsonRpcLogger.Info().Msg("RPC channel not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestString := string(requestBytes)
|
requestString := string(requestBytes)
|
||||||
scopedLogger := jsonRpcLogger.With().
|
scopedLogger := jsonRpcLogger.With().
|
||||||
|
|
@ -91,12 +132,53 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(requestString)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
|
// Only log at debug level - closed pipe errors are expected during reconnection
|
||||||
|
scopedLogger.Debug().Err(err).Str("event", event).Msg("Could not send JSONRPC event (channel may be closing)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func broadcastJSONRPCEvent(event string, params any) {
|
||||||
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
|
writeJSONRPCEvent(event, params, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
|
// Global rate limit check (protects against coordinated DoS from multiple sessions)
|
||||||
|
if !checkGlobalRPCRateLimit() {
|
||||||
|
jsonRpcLogger.Warn().
|
||||||
|
Str("sessionId", session.ID).
|
||||||
|
Msg("Global RPC rate limit exceeded")
|
||||||
|
errorResponse := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: map[string]any{
|
||||||
|
"code": -32000,
|
||||||
|
"message": "Global rate limit exceeded",
|
||||||
|
},
|
||||||
|
ID: 0,
|
||||||
|
}
|
||||||
|
writeJSONRPCResponse(errorResponse, session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-session rate limit check (DoS protection)
|
||||||
|
if !session.CheckRPCRateLimit() {
|
||||||
|
jsonRpcLogger.Warn().
|
||||||
|
Str("sessionId", session.ID).
|
||||||
|
Msg("RPC rate limit exceeded")
|
||||||
|
errorResponse := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: map[string]any{
|
||||||
|
"code": -32000,
|
||||||
|
"message": "Rate limit exceeded",
|
||||||
|
},
|
||||||
|
ID: 0,
|
||||||
|
}
|
||||||
|
writeJSONRPCResponse(errorResponse, session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var request JSONRPCRequest
|
var request JSONRPCRequest
|
||||||
err := json.Unmarshal(message.Data, &request)
|
err := json.Unmarshal(message.Data, &request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -124,21 +206,62 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
|
|
||||||
scopedLogger.Trace().Msg("Received RPC request")
|
scopedLogger.Trace().Msg("Received RPC request")
|
||||||
|
|
||||||
handler, ok := rpcHandlers[request.Method]
|
var result any
|
||||||
if !ok {
|
var handlerErr error
|
||||||
errorResponse := JSONRPCResponse{
|
|
||||||
JSONRPC: "2.0",
|
// Handle session management RPC methods
|
||||||
Error: map[string]any{
|
switch request.Method {
|
||||||
"code": -32601,
|
case "approvePrimaryRequest", "denyPrimaryRequest":
|
||||||
"message": "Method not found",
|
result, handlerErr = handleSessionTransferRPC(request.Method, request.Params, session)
|
||||||
},
|
case "approveNewSession", "denyNewSession":
|
||||||
ID: request.ID,
|
result, handlerErr = handleSessionApprovalRPC(request.Method, request.Params, session)
|
||||||
|
case "requestSessionApproval":
|
||||||
|
result, handlerErr = handleRequestSessionApprovalRPC(session)
|
||||||
|
case "updateSessionNickname":
|
||||||
|
result, handlerErr = handleUpdateSessionNicknameRPC(request.Params, session)
|
||||||
|
case "getSessions":
|
||||||
|
result = sessionManager.GetAllSessions()
|
||||||
|
case "getPermissions":
|
||||||
|
result, handlerErr = handleGetPermissionsRPC(session)
|
||||||
|
case "getSessionSettings", "setSessionSettings":
|
||||||
|
result, handlerErr = handleSessionSettingsRPC(request.Method, request.Params, session)
|
||||||
|
case "generateNickname":
|
||||||
|
result, handlerErr = handleGenerateNicknameRPC(request.Params)
|
||||||
|
default:
|
||||||
|
// Check method permissions using centralized permission system
|
||||||
|
if requiredPerm, exists := GetMethodPermission(request.Method); exists {
|
||||||
|
if !session.HasPermission(requiredPerm) {
|
||||||
|
errorResponse := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: map[string]any{
|
||||||
|
"code": -32603,
|
||||||
|
"message": fmt.Sprintf("Permission denied: %s required", requiredPerm),
|
||||||
|
},
|
||||||
|
ID: request.ID,
|
||||||
|
}
|
||||||
|
writeJSONRPCResponse(errorResponse, session)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeJSONRPCResponse(errorResponse, session)
|
|
||||||
return
|
// Fall back to regular handlers
|
||||||
|
handler, ok := rpcHandlers[request.Method]
|
||||||
|
if !ok {
|
||||||
|
errorResponse := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: map[string]any{
|
||||||
|
"code": -32601,
|
||||||
|
"message": "Method not found",
|
||||||
|
},
|
||||||
|
ID: request.ID,
|
||||||
|
}
|
||||||
|
writeJSONRPCResponse(errorResponse, session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, handlerErr = callRPCHandler(scopedLogger, handler, request.Params)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := callRPCHandler(scopedLogger, handler, request.Params)
|
err = handlerErr
|
||||||
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{
|
||||||
|
|
@ -154,7 +277,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
|
scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully")
|
||||||
|
|
||||||
response := JSONRPCResponse{
|
response := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
|
|
@ -175,7 +298,7 @@ func rpcGetDeviceID() (string, error) {
|
||||||
func rpcReboot(force bool) error {
|
func rpcReboot(force bool) error {
|
||||||
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
|
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
|
||||||
|
|
||||||
writeJSONRPCEvent("willReboot", nil, currentSession)
|
broadcastJSONRPCEvent("willReboot", nil)
|
||||||
|
|
||||||
// Wait for the JSONRPCEvent to be sent
|
// Wait for the JSONRPCEvent to be sent
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
@ -1088,6 +1211,78 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetSessions() ([]SessionData, error) {
|
||||||
|
return sessionManager.GetAllSessions(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetSessionData(sessionId string) (SessionData, error) {
|
||||||
|
session := sessionManager.GetSession(sessionId)
|
||||||
|
if session == nil {
|
||||||
|
return SessionData{}, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return SessionData{
|
||||||
|
ID: session.ID,
|
||||||
|
Mode: session.Mode,
|
||||||
|
Source: session.Source,
|
||||||
|
Identity: session.Identity,
|
||||||
|
CreatedAt: session.CreatedAt,
|
||||||
|
LastActive: session.LastActive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcRequestPrimary(sessionId string) map[string]interface{} {
|
||||||
|
err := sessionManager.RequestPrimary(sessionId)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the session was immediately promoted or queued
|
||||||
|
session := sessionManager.GetSession(sessionId)
|
||||||
|
if session == nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": "session not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"mode": string(session.Mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcReleasePrimary(sessionId string) error {
|
||||||
|
return sessionManager.ReleasePrimary(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcTransferPrimary(fromId string, toId string) error {
|
||||||
|
return sessionManager.TransferPrimary(fromId, toId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetSessionConfig() (map[string]interface{}, error) {
|
||||||
|
maxSessions := 10
|
||||||
|
primaryTimeout := 300
|
||||||
|
|
||||||
|
if config != nil && config.MultiSession != nil {
|
||||||
|
if config.MultiSession.MaxSessions > 0 {
|
||||||
|
maxSessions = config.MultiSession.MaxSessions
|
||||||
|
}
|
||||||
|
if config.MultiSession.PrimaryTimeout > 0 {
|
||||||
|
primaryTimeout = config.MultiSession.PrimaryTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"maxSessions": maxSessions,
|
||||||
|
"primaryTimeout": primaryTimeout,
|
||||||
|
"allowCloudOverride": true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
keyboardMacroCancel context.CancelFunc
|
keyboardMacroCancel context.CancelFunc
|
||||||
keyboardMacroLock sync.Mutex
|
keyboardMacroLock sync.Mutex
|
||||||
|
|
@ -1123,8 +1318,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
||||||
IsPaste: true,
|
IsPaste: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentSession != nil {
|
// Report to primary session if exists
|
||||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
|
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
||||||
|
|
@ -1132,8 +1328,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
||||||
setKeyboardMacroCancel(nil)
|
setKeyboardMacroCancel(nil)
|
||||||
|
|
||||||
s.State = false
|
s.State = false
|
||||||
if currentSession != nil {
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
@ -1273,4 +1469,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||||
|
"getSessions": {Func: rpcGetSessions},
|
||||||
|
"getSessionData": {Func: rpcGetSessionData, Params: []string{"sessionId"}},
|
||||||
|
"getSessionConfig": {Func: rpcGetSessionConfig},
|
||||||
|
"requestPrimary": {Func: rpcRequestPrimary, Params: []string{"sessionId"}},
|
||||||
|
"releasePrimary": {Func: rpcReleasePrimary, Params: []string{"sessionId"}},
|
||||||
|
"transferPrimary": {Func: rpcTransferPrimary, Params: []string{"fromId", "toId"}},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSessionTransferRPC handles primary control transfer requests (approve/deny)
|
||||||
|
func handleSessionTransferRPC(method string, params map[string]any, session *Session) (any, error) {
|
||||||
|
requesterID, ok := params["requesterID"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid requesterID parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RequirePermission(session, PermissionSessionTransfer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch method {
|
||||||
|
case "approvePrimaryRequest":
|
||||||
|
err = sessionManager.ApprovePrimaryRequest(session.ID, requesterID)
|
||||||
|
if err == nil {
|
||||||
|
return map[string]interface{}{"status": "approved"}, nil
|
||||||
|
}
|
||||||
|
case "denyPrimaryRequest":
|
||||||
|
err = sessionManager.DenyPrimaryRequest(session.ID, requesterID)
|
||||||
|
if err == nil {
|
||||||
|
return map[string]interface{}{"status": "denied"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionApprovalRPC handles new session approval requests (approve/deny)
|
||||||
|
func handleSessionApprovalRPC(method string, params map[string]any, session *Session) (any, error) {
|
||||||
|
sessionID, ok := params["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid sessionId parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch method {
|
||||||
|
case "approveNewSession":
|
||||||
|
err = sessionManager.ApproveSession(sessionID)
|
||||||
|
if err == nil {
|
||||||
|
go sessionManager.broadcastSessionListUpdate()
|
||||||
|
return map[string]interface{}{"status": "approved"}, nil
|
||||||
|
}
|
||||||
|
case "denyNewSession":
|
||||||
|
err = sessionManager.DenySession(sessionID)
|
||||||
|
if err == nil {
|
||||||
|
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
|
||||||
|
go func() {
|
||||||
|
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
|
||||||
|
"message": "Access denied by primary session",
|
||||||
|
}, targetSession)
|
||||||
|
sessionManager.broadcastSessionListUpdate()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "denied"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestSessionApprovalRPC handles pending sessions requesting approval from primary
|
||||||
|
func handleRequestSessionApprovalRPC(session *Session) (any, error) {
|
||||||
|
if session.Mode != SessionModePending {
|
||||||
|
return nil, errors.New("only pending sessions can request approval")
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSessionSettings == nil || !currentSessionSettings.RequireApproval {
|
||||||
|
return nil, errors.New("session approval not required")
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := sessionManager.GetPrimarySession()
|
||||||
|
if primary == nil {
|
||||||
|
return nil, errors.New("no primary session available")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
|
||||||
|
"sessionId": session.ID,
|
||||||
|
"source": session.Source,
|
||||||
|
"identity": session.Identity,
|
||||||
|
"nickname": session.Nickname,
|
||||||
|
}, primary)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return map[string]interface{}{"status": "requested"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNickname(nickname string) error {
|
||||||
|
if len(nickname) < minNicknameLength {
|
||||||
|
return fmt.Errorf("nickname must be at least %d characters", minNicknameLength)
|
||||||
|
}
|
||||||
|
if len(nickname) > maxNicknameLength {
|
||||||
|
return fmt.Errorf("nickname must be %d characters or less", maxNicknameLength)
|
||||||
|
}
|
||||||
|
if !isValidNickname(nickname) {
|
||||||
|
return errors.New("nickname can only contain letters, numbers, spaces, and - _ . @")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, r := range nickname {
|
||||||
|
if r < 32 || r == 127 {
|
||||||
|
return fmt.Errorf("nickname contains control character at position %d", i)
|
||||||
|
}
|
||||||
|
if r >= 0x200B && r <= 0x200D {
|
||||||
|
return errors.New("nickname contains zero-width character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := ""
|
||||||
|
for _, r := range nickname {
|
||||||
|
trimmed += string(r)
|
||||||
|
}
|
||||||
|
if trimmed != nickname {
|
||||||
|
return errors.New("nickname contains disallowed unicode")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateSessionNicknameRPC(params map[string]any, session *Session) (any, error) {
|
||||||
|
sessionID, _ := params["sessionId"].(string)
|
||||||
|
nickname, _ := params["nickname"].(string)
|
||||||
|
|
||||||
|
if err := validateNickname(nickname); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSession := sessionManager.GetSession(sessionID)
|
||||||
|
if targetSession == nil {
|
||||||
|
return nil, errors.New("session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetSession.ID != session.ID && !session.HasPermission(PermissionSessionManage) {
|
||||||
|
return nil, errors.New("permission denied: can only update own nickname")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nickname uniqueness
|
||||||
|
allSessions := sessionManager.GetAllSessions()
|
||||||
|
for _, existingSession := range allSessions {
|
||||||
|
if existingSession.ID != sessionID && existingSession.Nickname == nickname {
|
||||||
|
return nil, fmt.Errorf("nickname '%s' is already in use by another session", nickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSession.Nickname = nickname
|
||||||
|
|
||||||
|
// If session is pending and approval is required, send the approval request now that we have a nickname
|
||||||
|
if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
go func() {
|
||||||
|
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
|
||||||
|
"sessionId": targetSession.ID,
|
||||||
|
"source": targetSession.Source,
|
||||||
|
"identity": targetSession.Identity,
|
||||||
|
"nickname": targetSession.Nickname,
|
||||||
|
}, primary)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionManager.broadcastSessionListUpdate()
|
||||||
|
return map[string]interface{}{"status": "updated"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetPermissionsRPC returns permissions for the current session
|
||||||
|
func handleGetPermissionsRPC(session *Session) (any, error) {
|
||||||
|
permissions := session.GetPermissions()
|
||||||
|
permMap := make(map[string]bool)
|
||||||
|
for perm, allowed := range permissions {
|
||||||
|
permMap[string(perm)] = allowed
|
||||||
|
}
|
||||||
|
return GetPermissionsResponse{
|
||||||
|
Mode: string(session.Mode),
|
||||||
|
Permissions: permMap,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionSettingsRPC handles getting or setting session settings
|
||||||
|
func handleSessionSettingsRPC(method string, params map[string]any, session *Session) (any, error) {
|
||||||
|
switch method {
|
||||||
|
case "getSessionSettings":
|
||||||
|
if err := RequirePermission(session, PermissionSettingsRead); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return currentSessionSettings, nil
|
||||||
|
|
||||||
|
case "setSessionSettings":
|
||||||
|
if err := RequirePermission(session, PermissionSessionManage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, ok := params["settings"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid settings parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if requireApproval, ok := settings["requireApproval"].(bool); ok {
|
||||||
|
currentSessionSettings.RequireApproval = requireApproval
|
||||||
|
}
|
||||||
|
if requireNickname, ok := settings["requireNickname"].(bool); ok {
|
||||||
|
currentSessionSettings.RequireNickname = requireNickname
|
||||||
|
}
|
||||||
|
if reconnectGrace, ok := settings["reconnectGrace"].(float64); ok {
|
||||||
|
currentSessionSettings.ReconnectGrace = int(reconnectGrace)
|
||||||
|
}
|
||||||
|
if primaryTimeout, ok := settings["primaryTimeout"].(float64); ok {
|
||||||
|
currentSessionSettings.PrimaryTimeout = int(primaryTimeout)
|
||||||
|
}
|
||||||
|
if privateKeystrokes, ok := settings["privateKeystrokes"].(bool); ok {
|
||||||
|
currentSessionSettings.PrivateKeystrokes = privateKeystrokes
|
||||||
|
}
|
||||||
|
if maxRejectionAttempts, ok := settings["maxRejectionAttempts"].(float64); ok {
|
||||||
|
currentSessionSettings.MaxRejectionAttempts = int(maxRejectionAttempts)
|
||||||
|
}
|
||||||
|
if maxSessions, ok := settings["maxSessions"].(float64); ok {
|
||||||
|
currentSessionSettings.MaxSessions = int(maxSessions)
|
||||||
|
}
|
||||||
|
if observerTimeout, ok := settings["observerTimeout"].(float64); ok {
|
||||||
|
currentSessionSettings.ObserverTimeout = int(observerTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionManager != nil {
|
||||||
|
sessionManager.updateAllSessionNicknames()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return nil, errors.New("failed to save session settings")
|
||||||
|
}
|
||||||
|
return currentSessionSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unknown session settings method: %s", method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateNicknameRPC generates a nickname based on user agent
|
||||||
|
func handleGenerateNicknameRPC(params map[string]any) (any, error) {
|
||||||
|
userAgent := ""
|
||||||
|
if params != nil {
|
||||||
|
if ua, ok := params["userAgent"].(string); ok {
|
||||||
|
userAgent = ua
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = "Mozilla/5.0 (Unknown) Browser"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"nickname": generateNicknameFromUserAgent(userAgent),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
19
main.go
19
main.go
|
|
@ -16,6 +16,22 @@ var appCtx context.Context
|
||||||
func Main() {
|
func Main() {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|
||||||
|
// Initialize currentSessionSettings to use config's persistent SessionSettings
|
||||||
|
if config.SessionSettings == nil {
|
||||||
|
config.SessionSettings = &SessionSettings{
|
||||||
|
RequireApproval: false,
|
||||||
|
RequireNickname: false,
|
||||||
|
ReconnectGrace: 10,
|
||||||
|
PrivateKeystrokes: false,
|
||||||
|
MaxRejectionAttempts: 3,
|
||||||
|
}
|
||||||
|
_ = SaveConfig()
|
||||||
|
}
|
||||||
|
currentSessionSettings = config.SessionSettings
|
||||||
|
|
||||||
|
// Initialize global session manager (must be called after config and logger are ready)
|
||||||
|
initSessionManager()
|
||||||
|
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -92,7 +108,8 @@ func Main() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentSession != nil {
|
// Skip update if there's an active primary session
|
||||||
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
logger.Debug().Msg("skipping update since a session is active")
|
logger.Debug().Msg("skipping update since a session is active")
|
||||||
time.Sleep(1 * time.Minute)
|
time.Sleep(1 * time.Minute)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
22
native.go
22
native.go
|
|
@ -51,12 +51,24 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
|
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
|
||||||
if currentSession != nil {
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
|
if !sessionManager.CanReceiveVideo(s, currentSessionSettings) {
|
||||||
if err != nil {
|
return
|
||||||
nativeLogger.Warn().Err(err).Msg("error writing sample")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if s.VideoTrack != nil {
|
||||||
|
err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
|
||||||
|
if err != nil {
|
||||||
|
nativeLogger.Warn().
|
||||||
|
Str("sessionID", s.ID).
|
||||||
|
Err(err).
|
||||||
|
Msg("error writing sample to session")
|
||||||
|
} else {
|
||||||
|
// Update LastActive when video frame successfully sent (prevents observer timeout)
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,7 @@ func networkStateChanged(_ string, state types.InterfaceState) {
|
||||||
// do not block the main thread
|
// do not block the main thread
|
||||||
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
||||||
|
|
||||||
if currentSession != nil {
|
broadcastJSONRPCEvent("networkState", state.ToRpcInterfaceState())
|
||||||
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.Online {
|
if state.Online {
|
||||||
networkLogger.Info().Msg("network state changed to online, triggering time sync")
|
networkLogger.Info().Msg("network state changed to online, triggering time sync")
|
||||||
|
|
@ -261,7 +259,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
|
||||||
// If reboot required, send willReboot event before applying network config
|
// If reboot required, send willReboot event before applying network config
|
||||||
if rebootRequired {
|
if rebootRequired {
|
||||||
l.Info().Msg("Sending willReboot event before applying network config")
|
l.Info().Msg("Sending willReboot event before applying network config")
|
||||||
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
broadcastJSONRPCEvent("willReboot", postRebootAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
|
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
|
||||||
|
|
|
||||||
6
ota.go
6
ota.go
|
|
@ -302,11 +302,7 @@ var otaState = OTAState{}
|
||||||
|
|
||||||
func triggerOTAStateUpdate() {
|
func triggerOTAStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
if currentSession == nil {
|
broadcastJSONRPCEvent("otaState", otaState)
|
||||||
logger.Info().Msg("No active RPC session, skipping update state update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
25
serial.go
25
serial.go
|
|
@ -57,12 +57,10 @@ func runATXControl() {
|
||||||
newBtnRSTState := line[2] == '1'
|
newBtnRSTState := line[2] == '1'
|
||||||
newBtnPWRState := line[3] == '1'
|
newBtnPWRState := line[3] == '1'
|
||||||
|
|
||||||
if currentSession != nil {
|
broadcastJSONRPCEvent("atxState", ATXState{
|
||||||
writeJSONRPCEvent("atxState", ATXState{
|
Power: newLedPWRState,
|
||||||
Power: newLedPWRState,
|
HDD: newLedHDDState,
|
||||||
HDD: newLedHDDState,
|
})
|
||||||
}, currentSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newLedHDDState != ledHDDState ||
|
if newLedHDDState != ledHDDState ||
|
||||||
newLedPWRState != ledPWRState ||
|
newLedPWRState != ledPWRState ||
|
||||||
|
|
@ -210,9 +208,7 @@ func runDCControl() {
|
||||||
// Update Prometheus metrics
|
// Update Prometheus metrics
|
||||||
updateDCMetrics(dcState)
|
updateDCMetrics(dcState)
|
||||||
|
|
||||||
if currentSession != nil {
|
broadcastJSONRPCEvent("dcState", dcState)
|
||||||
writeJSONRPCEvent("dcState", dcState, currentSession)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,9 +280,16 @@ func reopenSerialPort() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSerialChannel(d *webrtc.DataChannel) {
|
func handleSerialChannel(d *webrtc.DataChannel, session *Session) {
|
||||||
scopedLogger := serialLogger.With().
|
scopedLogger := serialLogger.With().
|
||||||
Uint16("data_channel_id", *d.ID()).Logger()
|
Uint16("data_channel_id", *d.ID()).
|
||||||
|
Str("session_id", session.ID).Logger()
|
||||||
|
|
||||||
|
// Check serial access permission
|
||||||
|
if !session.HasPermission(PermissionSerialAccess) {
|
||||||
|
handlePermissionDeniedChannel(d, "Serial port access denied: Permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
d.OnOpen(func() {
|
d.OnOpen(func() {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emergencyPromotionContext holds context for emergency promotion attempts
|
||||||
|
type emergencyPromotionContext struct {
|
||||||
|
triggerSessionID string
|
||||||
|
triggerReason string
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptEmergencyPromotion tries to promote a session using emergency or normal promotion logic
|
||||||
|
// Returns (promotedSessionID, isEmergency, shouldSkip)
|
||||||
|
func (sm *SessionManager) attemptEmergencyPromotion(ctx emergencyPromotionContext, excludeSessionID string) (string, bool, bool) {
|
||||||
|
// Check if emergency promotion is needed
|
||||||
|
if currentSessionSettings == nil || !currentSessionSettings.RequireApproval {
|
||||||
|
// Normal promotion - reset consecutive counter
|
||||||
|
sm.consecutiveEmergencyPromotions = 0
|
||||||
|
promotedID := sm.findNextSessionToPromote()
|
||||||
|
return promotedID, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.emergencyWindowMutex.Lock()
|
||||||
|
defer sm.emergencyWindowMutex.Unlock()
|
||||||
|
|
||||||
|
// CRITICAL: Bypass all rate limits if no primary exists to prevent deadlock
|
||||||
|
// System availability takes priority over DoS protection
|
||||||
|
noPrimaryExists := (sm.primarySessionID == "")
|
||||||
|
if noPrimaryExists {
|
||||||
|
sm.logger.Info().
|
||||||
|
Str("triggerSessionID", ctx.triggerSessionID).
|
||||||
|
Str("triggerReason", ctx.triggerReason).
|
||||||
|
Msg("Bypassing emergency promotion rate limits - no primary exists")
|
||||||
|
promotedSessionID := sm.findMostTrustedSessionForEmergency()
|
||||||
|
return promotedSessionID, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
const slidingWindowDuration = 60 * time.Second
|
||||||
|
const maxEmergencyPromotionsPerMinute = 3
|
||||||
|
|
||||||
|
cutoff := ctx.now.Add(-slidingWindowDuration)
|
||||||
|
validEntries := make([]time.Time, 0, len(sm.emergencyPromotionWindow))
|
||||||
|
for _, t := range sm.emergencyPromotionWindow {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
validEntries = append(validEntries, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sm.emergencyPromotionWindow = validEntries
|
||||||
|
|
||||||
|
if len(sm.emergencyPromotionWindow) >= maxEmergencyPromotionsPerMinute {
|
||||||
|
sm.logger.Error().
|
||||||
|
Str("triggerSessionID", ctx.triggerSessionID).
|
||||||
|
Int("promotionsInLastMinute", len(sm.emergencyPromotionWindow)).
|
||||||
|
Msg("Emergency promotion rate limit exceeded - potential attack")
|
||||||
|
return "", false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.now.Sub(sm.lastEmergencyPromotion) < 10*time.Second {
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("triggerSessionID", ctx.triggerSessionID).
|
||||||
|
Dur("timeSinceLastEmergency", ctx.now.Sub(sm.lastEmergencyPromotion)).
|
||||||
|
Msg("Emergency promotion cooldown active")
|
||||||
|
return "", false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.consecutiveEmergencyPromotions >= 3 {
|
||||||
|
sm.logger.Error().
|
||||||
|
Str("triggerSessionID", ctx.triggerSessionID).
|
||||||
|
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
|
||||||
|
Msg("Too many consecutive emergency promotions - blocking")
|
||||||
|
return "", false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best session for emergency promotion
|
||||||
|
var promotedSessionID string
|
||||||
|
if excludeSessionID != "" {
|
||||||
|
// Need to exclude a specific session (e.g., timed-out session)
|
||||||
|
bestSessionID := ""
|
||||||
|
bestScore := -1
|
||||||
|
for id, session := range sm.sessions {
|
||||||
|
if id != excludeSessionID &&
|
||||||
|
!sm.isSessionBlacklisted(id) &&
|
||||||
|
(session.Mode == SessionModeObserver || session.Mode == SessionModeQueued) {
|
||||||
|
score := sm.getSessionTrustScore(id)
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestSessionID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotedSessionID = bestSessionID
|
||||||
|
} else {
|
||||||
|
promotedSessionID = sm.findMostTrustedSessionForEmergency()
|
||||||
|
}
|
||||||
|
|
||||||
|
return promotedSessionID, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGracePeriodExpiration checks and handles expired grace periods
|
||||||
|
// Returns true if any grace period expired
|
||||||
|
func (sm *SessionManager) handleGracePeriodExpiration(now time.Time) bool {
|
||||||
|
gracePeriodExpired := false
|
||||||
|
for sessionID, graceTime := range sm.reconnectGrace {
|
||||||
|
if now.After(graceTime) {
|
||||||
|
delete(sm.reconnectGrace, sessionID)
|
||||||
|
gracePeriodExpired = true
|
||||||
|
|
||||||
|
wasHoldingPrimarySlot := (sm.lastPrimaryID == sessionID)
|
||||||
|
|
||||||
|
if wasHoldingPrimarySlot {
|
||||||
|
sm.primarySessionID = ""
|
||||||
|
sm.lastPrimaryID = ""
|
||||||
|
|
||||||
|
sm.logger.Info().
|
||||||
|
Str("expiredSessionID", sessionID).
|
||||||
|
Msg("Primary session grace period expired - slot now available")
|
||||||
|
|
||||||
|
// Promote next eligible session using emergency logic if needed
|
||||||
|
sm.promoteAfterGraceExpiration(sessionID, now)
|
||||||
|
} else {
|
||||||
|
sm.logger.Debug().
|
||||||
|
Str("expiredSessionID", sessionID).
|
||||||
|
Msg("Non-primary session grace period expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(sm.reconnectInfo, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gracePeriodExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// promoteAfterGraceExpiration handles promotion after grace period expiration
|
||||||
|
func (sm *SessionManager) promoteAfterGraceExpiration(expiredSessionID string, now time.Time) {
|
||||||
|
ctx := emergencyPromotionContext{
|
||||||
|
triggerSessionID: expiredSessionID,
|
||||||
|
triggerReason: "grace_expiration",
|
||||||
|
now: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
promotedSessionID, isEmergency, shouldSkip := sm.attemptEmergencyPromotion(ctx, "")
|
||||||
|
if shouldSkip {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if promotedSessionID != "" {
|
||||||
|
reason := "grace_expiration_promotion"
|
||||||
|
if isEmergency {
|
||||||
|
reason = "emergency_promotion_deadlock_prevention"
|
||||||
|
sm.emergencyWindowMutex.Lock()
|
||||||
|
sm.emergencyPromotionWindow = append(sm.emergencyPromotionWindow, now)
|
||||||
|
sm.emergencyWindowMutex.Unlock()
|
||||||
|
sm.lastEmergencyPromotion = now
|
||||||
|
sm.consecutiveEmergencyPromotions++
|
||||||
|
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("expiredSessionID", expiredSessionID).
|
||||||
|
Str("promotedSessionID", promotedSessionID).
|
||||||
|
Bool("requireApproval", true).
|
||||||
|
Int("consecutiveEmergencyPromotions", sm.consecutiveEmergencyPromotions).
|
||||||
|
Int("trustScore", sm.getSessionTrustScore(promotedSessionID)).
|
||||||
|
Msg("EMERGENCY: Bypassing approval requirement to prevent deadlock")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sm.transferPrimaryRole("", promotedSessionID, reason, "primary grace period expired")
|
||||||
|
if err == nil {
|
||||||
|
logEvent := sm.logger.Info()
|
||||||
|
if isEmergency {
|
||||||
|
logEvent = sm.logger.Warn()
|
||||||
|
}
|
||||||
|
logEvent.
|
||||||
|
Str("expiredSessionID", expiredSessionID).
|
||||||
|
Str("promotedSessionID", promotedSessionID).
|
||||||
|
Str("reason", reason).
|
||||||
|
Bool("isEmergencyPromotion", isEmergency).
|
||||||
|
Msg("Auto-promoted session after primary grace period expiration")
|
||||||
|
} else {
|
||||||
|
sm.logger.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("expiredSessionID", expiredSessionID).
|
||||||
|
Str("promotedSessionID", promotedSessionID).
|
||||||
|
Str("reason", reason).
|
||||||
|
Bool("isEmergencyPromotion", isEmergency).
|
||||||
|
Msg("Failed to promote session after grace period expiration")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logLevel := sm.logger.Info()
|
||||||
|
if isEmergency {
|
||||||
|
logLevel = sm.logger.Error()
|
||||||
|
}
|
||||||
|
logLevel.
|
||||||
|
Str("expiredSessionID", expiredSessionID).
|
||||||
|
Bool("isEmergencyPromotion", isEmergency).
|
||||||
|
Msg("Primary grace period expired but no eligible sessions to promote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePendingSessionTimeout removes timed-out pending sessions (DoS protection)
|
||||||
|
// Returns true if any pending session was removed
|
||||||
|
func (sm *SessionManager) handlePendingSessionTimeout(now time.Time) bool {
|
||||||
|
toDelete := make([]string, 0)
|
||||||
|
for id, session := range sm.sessions {
|
||||||
|
if session.Mode == SessionModePending &&
|
||||||
|
now.Sub(session.CreatedAt) > defaultPendingSessionTimeout {
|
||||||
|
websocketLogger.Debug().
|
||||||
|
Str("sessionId", id).
|
||||||
|
Dur("age", now.Sub(session.CreatedAt)).
|
||||||
|
Msg("Removing timed-out pending session")
|
||||||
|
toDelete = append(toDelete, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range toDelete {
|
||||||
|
delete(sm.sessions, id)
|
||||||
|
}
|
||||||
|
return len(toDelete) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleObserverSessionCleanup removes inactive observer sessions with closed RPC channels
|
||||||
|
// Returns true if any observer session was removed
|
||||||
|
func (sm *SessionManager) handleObserverSessionCleanup(now time.Time) bool {
|
||||||
|
observerTimeout := defaultObserverSessionTimeout
|
||||||
|
if currentSessionSettings != nil && currentSessionSettings.ObserverTimeout > 0 {
|
||||||
|
observerTimeout = time.Duration(currentSessionSettings.ObserverTimeout) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
toDelete := make([]string, 0)
|
||||||
|
for id, session := range sm.sessions {
|
||||||
|
if session.Mode == SessionModeObserver {
|
||||||
|
if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout {
|
||||||
|
sm.logger.Debug().
|
||||||
|
Str("sessionId", id).
|
||||||
|
Dur("inactiveFor", now.Sub(session.LastActive)).
|
||||||
|
Dur("observerTimeout", observerTimeout).
|
||||||
|
Msg("Removing inactive observer session with closed RPC channel")
|
||||||
|
toDelete = append(toDelete, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range toDelete {
|
||||||
|
delete(sm.sessions, id)
|
||||||
|
}
|
||||||
|
return len(toDelete) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePrimarySessionTimeout checks and handles primary session timeout
|
||||||
|
// Returns true if primary session was timed out and cleanup is needed
|
||||||
|
func (sm *SessionManager) handlePrimarySessionTimeout(now time.Time) bool {
|
||||||
|
if sm.primarySessionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, exists := sm.sessions[sm.primarySessionID]
|
||||||
|
if !exists {
|
||||||
|
sm.primarySessionID = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTimeout := sm.getCurrentPrimaryTimeout()
|
||||||
|
if now.Sub(primary.LastActive) <= currentTimeout {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout detected - demote primary
|
||||||
|
timedOutSessionID := primary.ID
|
||||||
|
primary.Mode = SessionModeObserver
|
||||||
|
sm.primarySessionID = ""
|
||||||
|
|
||||||
|
ctx := emergencyPromotionContext{
|
||||||
|
triggerSessionID: timedOutSessionID,
|
||||||
|
triggerReason: "timeout",
|
||||||
|
now: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
promotedSessionID, isEmergency, shouldSkip := sm.attemptEmergencyPromotion(ctx, timedOutSessionID)
|
||||||
|
if shouldSkip {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if promotedSessionID != "" {
|
||||||
|
reason := "timeout_promotion"
|
||||||
|
if isEmergency {
|
||||||
|
reason = "emergency_timeout_promotion"
|
||||||
|
sm.emergencyWindowMutex.Lock()
|
||||||
|
sm.emergencyPromotionWindow = append(sm.emergencyPromotionWindow, now)
|
||||||
|
sm.emergencyWindowMutex.Unlock()
|
||||||
|
sm.lastEmergencyPromotion = now
|
||||||
|
sm.consecutiveEmergencyPromotions++
|
||||||
|
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("timedOutSessionID", timedOutSessionID).
|
||||||
|
Str("promotedSessionID", promotedSessionID).
|
||||||
|
Bool("requireApproval", true).
|
||||||
|
Int("trustScore", sm.getSessionTrustScore(promotedSessionID)).
|
||||||
|
Msg("EMERGENCY: Timeout promotion bypassing approval requirement")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sm.transferPrimaryRole(timedOutSessionID, promotedSessionID, reason, "primary session timeout")
|
||||||
|
if err == nil {
|
||||||
|
logEvent := sm.logger.Info()
|
||||||
|
if isEmergency {
|
||||||
|
logEvent = sm.logger.Warn()
|
||||||
|
}
|
||||||
|
logEvent.
|
||||||
|
Str("timedOutSessionID", timedOutSessionID).
|
||||||
|
Str("promotedSessionID", promotedSessionID).
|
||||||
|
Bool("isEmergencyPromotion", isEmergency).
|
||||||
|
Msg("Auto-promoted session after primary timeout")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jetkvm/kvm/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Permission = session.Permission
|
||||||
|
PermissionSet = session.PermissionSet
|
||||||
|
SessionMode = session.SessionMode
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionModePrimary = session.SessionModePrimary
|
||||||
|
SessionModeObserver = session.SessionModeObserver
|
||||||
|
SessionModeQueued = session.SessionModeQueued
|
||||||
|
SessionModePending = session.SessionModePending
|
||||||
|
|
||||||
|
PermissionVideoView = session.PermissionVideoView
|
||||||
|
PermissionKeyboardInput = session.PermissionKeyboardInput
|
||||||
|
PermissionMouseInput = session.PermissionMouseInput
|
||||||
|
PermissionPaste = session.PermissionPaste
|
||||||
|
PermissionSessionTransfer = session.PermissionSessionTransfer
|
||||||
|
PermissionSessionApprove = session.PermissionSessionApprove
|
||||||
|
PermissionSessionKick = session.PermissionSessionKick
|
||||||
|
PermissionSessionRequestPrimary = session.PermissionSessionRequestPrimary
|
||||||
|
PermissionSessionReleasePrimary = session.PermissionSessionReleasePrimary
|
||||||
|
PermissionSessionManage = session.PermissionSessionManage
|
||||||
|
PermissionPowerControl = session.PermissionPowerControl
|
||||||
|
PermissionUSBControl = session.PermissionUSBControl
|
||||||
|
PermissionMountMedia = session.PermissionMountMedia
|
||||||
|
PermissionUnmountMedia = session.PermissionUnmountMedia
|
||||||
|
PermissionMountList = session.PermissionMountList
|
||||||
|
PermissionExtensionManage = session.PermissionExtensionManage
|
||||||
|
PermissionExtensionATX = session.PermissionExtensionATX
|
||||||
|
PermissionExtensionDC = session.PermissionExtensionDC
|
||||||
|
PermissionExtensionSerial = session.PermissionExtensionSerial
|
||||||
|
PermissionExtensionWOL = session.PermissionExtensionWOL
|
||||||
|
PermissionTerminalAccess = session.PermissionTerminalAccess
|
||||||
|
PermissionSerialAccess = session.PermissionSerialAccess
|
||||||
|
PermissionSettingsRead = session.PermissionSettingsRead
|
||||||
|
PermissionSettingsWrite = session.PermissionSettingsWrite
|
||||||
|
PermissionSettingsAccess = session.PermissionSettingsAccess
|
||||||
|
PermissionSystemReboot = session.PermissionSystemReboot
|
||||||
|
PermissionSystemUpdate = session.PermissionSystemUpdate
|
||||||
|
PermissionSystemNetwork = session.PermissionSystemNetwork
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
GetMethodPermission = session.GetMethodPermission
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetPermissionsResponse = session.GetPermissionsResponse
|
||||||
|
|
||||||
|
func (s *Session) HasPermission(perm Permission) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.CheckPermission(s.Mode, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) GetPermissions() PermissionSet {
|
||||||
|
if s == nil {
|
||||||
|
return PermissionSet{}
|
||||||
|
}
|
||||||
|
return session.GetPermissionsForMode(s.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirePermission(s *Session, perm Permission) error {
|
||||||
|
if s == nil {
|
||||||
|
return session.RequirePermissionForMode(SessionModePending, perm)
|
||||||
|
}
|
||||||
|
if !s.HasPermission(perm) {
|
||||||
|
return session.RequirePermissionForMode(s.Mode, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
terminal.go
11
terminal.go
|
|
@ -16,9 +16,16 @@ type TerminalSize struct {
|
||||||
Cols int `json:"cols"`
|
Cols int `json:"cols"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTerminalChannel(d *webrtc.DataChannel) {
|
func handleTerminalChannel(d *webrtc.DataChannel, session *Session) {
|
||||||
scopedLogger := terminalLogger.With().
|
scopedLogger := terminalLogger.With().
|
||||||
Uint16("data_channel_id", *d.ID()).Logger()
|
Uint16("data_channel_id", *d.ID()).
|
||||||
|
Str("session_id", session.ID).Logger()
|
||||||
|
|
||||||
|
// Check terminal access permission
|
||||||
|
if !session.HasPermission(PermissionTerminalAccess) {
|
||||||
|
handlePermissionDeniedChannel(d, "Terminal access denied: Permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var ptmx *os.File
|
var ptmx *os.File
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { SessionInfo } from "@/stores/sessionStore";
|
||||||
|
|
||||||
|
interface JsonRpcResponse {
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: JsonRpcResponse) => void) => void;
|
||||||
|
|
||||||
|
export const sessionApi = {
|
||||||
|
getSessions: async (sendFn: RpcSendFunction): Promise<SessionInfo[]> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("getSessions", {}, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve((response.result as SessionInfo[]) || []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionInfo: async (sendFn: RpcSendFunction, sessionId: string): Promise<SessionInfo> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("getSessionInfo", { sessionId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve(response.result as SessionInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("requestPrimary", { sessionId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve(response.result as { status: string; mode?: string; message?: string });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
releasePrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("releasePrimary", { sessionId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
transferPrimary: async (
|
||||||
|
sendFn: RpcSendFunction,
|
||||||
|
fromId: string,
|
||||||
|
toId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("transferPrimary", { fromId, toId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNickname: async (
|
||||||
|
sendFn: RpcSendFunction,
|
||||||
|
sessionId: string,
|
||||||
|
nickname: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("updateSessionNickname", { sessionId, nickname }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
approveNewSession: async (
|
||||||
|
sendFn: RpcSendFunction,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("approveNewSession", { sessionId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
denyNewSession: async (
|
||||||
|
sendFn: RpcSendFunction,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("denyNewSession", { sessionId }, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
requestSessionApproval: async (sendFn: RpcSendFunction): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sendFn("requestSessionApproval", {}, (response: JsonRpcResponse) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { XCircleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
import { DEVICE_API, CLOUD_API } from "@/ui.config";
|
||||||
|
import { isOnDevice } from "@/main";
|
||||||
|
import { useUserStore, useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
import api from "@/api";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
interface AccessDeniedOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onRequestApproval?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessDeniedOverlay({
|
||||||
|
show,
|
||||||
|
message = "Your session access was denied",
|
||||||
|
onRetry,
|
||||||
|
onRequestApproval
|
||||||
|
}: AccessDeniedOverlayProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setUser = useUserStore(state => state.setUser);
|
||||||
|
const { clearSession, rejectionCount, incrementRejectionCount } = useSessionStore();
|
||||||
|
const { clearNickname } = useSharedSessionStore();
|
||||||
|
const { maxRejectionAttempts } = useSettingsStore();
|
||||||
|
const [countdown, setCountdown] = useState(10);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||||
|
const res = await api.POST(logoutUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn("Logout API call failed, but continuing with local cleanup");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout API call failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clear local state and navigate, regardless of API call result
|
||||||
|
setUser(null);
|
||||||
|
clearSession();
|
||||||
|
clearNickname();
|
||||||
|
navigate("/");
|
||||||
|
}, [navigate, setUser, clearSession, clearNickname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
|
||||||
|
const newCount = incrementRejectionCount();
|
||||||
|
|
||||||
|
if (newCount >= maxRejectionAttempts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
handleLogout();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
if (rejectionCount >= maxRejectionAttempts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||||
|
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<XCircleIcon className="h-8 w-8 text-red-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Access Denied
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-300">
|
||||||
|
The primary session has denied your access request. This could be for security reasons
|
||||||
|
or because the session is restricted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rejectionCount < maxRejectionAttempts && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<strong>Attempt {rejectionCount} of {maxRejectionAttempts}:</strong> {rejectionCount === maxRejectionAttempts - 1
|
||||||
|
? "This is your last attempt. Further rejections will hide this dialog."
|
||||||
|
: `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Redirecting in <span className="font-mono font-bold">{countdown}</span> seconds...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (isRetrying) return;
|
||||||
|
setIsRetrying(true);
|
||||||
|
try {
|
||||||
|
if (onRequestApproval) {
|
||||||
|
await onRequestApproval();
|
||||||
|
} else if (onRetry) {
|
||||||
|
await onRetry();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
theme="primary"
|
||||||
|
size="MD"
|
||||||
|
text={isRetrying ? "Requesting..." : "Request Access Again"}
|
||||||
|
disabled={isRetrying}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
theme="light"
|
||||||
|
size="MD"
|
||||||
|
text="Back to Login"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@ import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback, useRef, useEffect } from "react";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,14 +11,18 @@ import {
|
||||||
useMountMediaStore,
|
useMountMediaStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
} from "@/hooks/stores";
|
useRTCStore } from "@/hooks/stores";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import PasteModal from "@/components/popovers/PasteModal";
|
import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
import MountPopopover from "@/components/popovers/MountPopover";
|
import MountPopopover from "@/components/popovers/MountPopover";
|
||||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
|
import SessionPopover from "@/components/popovers/SessionPopover";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
|
|
@ -33,6 +37,40 @@ export default function Actionbar({
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
const { developerMode } = useSettingsStore();
|
const { developerMode } = useSettingsStore();
|
||||||
|
const { currentMode, sessions, setSessions } = useSessionStore();
|
||||||
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch sessions on mount if we have an RPC channel
|
||||||
|
useEffect(() => {
|
||||||
|
if (rpcDataChannel?.readyState === "open" && sessions.length === 0) {
|
||||||
|
const id = Math.random().toString(36).substring(2);
|
||||||
|
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id });
|
||||||
|
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(event.data);
|
||||||
|
if (response.id === id && response.result) {
|
||||||
|
setSessions(response.result);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for non-JSON messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rpcDataChannel.addEventListener("message", handler);
|
||||||
|
rpcDataChannel.send(message);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
rpcDataChannel.removeEventListener("message", handler);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
rpcDataChannel.removeEventListener("message", handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [rpcDataChannel, sessions.length, setSessions]);
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -44,7 +82,6 @@ export default function Actionbar({
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
console.debug("Popover is closing. Returning focus trap to video");
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +97,7 @@ export default function Actionbar({
|
||||||
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
|
||||||
>
|
>
|
||||||
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||||
{developerMode && (
|
{developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
|
|
@ -69,179 +106,239 @@ export default function Actionbar({
|
||||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Popover>
|
{hasPermission(Permission.PASTE) && (
|
||||||
<PopoverButton as={Fragment}>
|
<Popover>
|
||||||
|
<PopoverButton as={Fragment}>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="Paste text"
|
||||||
|
LeadingIcon={MdOutlineContentPasteGo}
|
||||||
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
transition
|
||||||
|
className={cx(
|
||||||
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
||||||
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ open }) => {
|
||||||
|
checkIfStateChanged(open);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<PasteModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.MOUNT_MEDIA) && (
|
||||||
|
<div className="relative">
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton as={Fragment}>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="Virtual Media"
|
||||||
|
LeadingIcon={({ className }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LuHardDrive className={className} />
|
||||||
|
<div
|
||||||
|
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
|
||||||
|
hidden: !remoteVirtualMediaState,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
transition
|
||||||
|
className={cx(
|
||||||
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
||||||
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ open }) => {
|
||||||
|
checkIfStateChanged(open);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<MountPopopover />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.EXTENSION_WOL) && (
|
||||||
|
<div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton as={Fragment}>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="Wake on LAN"
|
||||||
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}}
|
||||||
|
LeadingIcon={({ className }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
|
||||||
|
<path d="M6 8v1" />
|
||||||
|
<path d="M10 8v1" />
|
||||||
|
<path d="M14 8v1" />
|
||||||
|
<path d="M18 8v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
transition
|
||||||
|
style={{
|
||||||
|
transitionProperty: "opacity",
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
||||||
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ open }) => {
|
||||||
|
checkIfStateChanged(open);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<WakeOnLanModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
||||||
|
<div className="hidden lg:block">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Paste text"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={MdOutlineContentPasteGo}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => {
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
setDisableVideoFocusTrap(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</div>
|
||||||
<PopoverPanel
|
)}
|
||||||
anchor="bottom start"
|
</div>
|
||||||
transition
|
|
||||||
className={cx(
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||||
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
{/* Session Control */}
|
||||||
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ open }) => {
|
|
||||||
checkIfStateChanged(open);
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-xl">
|
|
||||||
<PasteModal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</PopoverPanel>
|
|
||||||
</Popover>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Virtual Media"
|
text={sessions.length > 0 ? `Sessions (${sessions.length})` : "Sessions"}
|
||||||
LeadingIcon={({ className }) => {
|
LeadingIcon={({ className }) => {
|
||||||
return (
|
const modeColor = currentMode === "primary" ? "text-green-500" :
|
||||||
<>
|
currentMode === "observer" ? "text-blue-500" :
|
||||||
<LuHardDrive className={className} />
|
currentMode === "queued" ? "text-yellow-500" :
|
||||||
<div
|
"text-slate-500";
|
||||||
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
|
return <UserGroupIcon className={cx(className, modeColor)} />;
|
||||||
hidden: !remoteVirtualMediaState,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableVideoFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
||||||
|
{/* Mode indicator dot */}
|
||||||
|
{currentMode && (
|
||||||
|
<div className="absolute -top-1 -right-1 pointer-events-none">
|
||||||
|
<div className={cx(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
currentMode === "primary" && "bg-green-500",
|
||||||
|
currentMode === "observer" && "bg-blue-500",
|
||||||
|
currentMode === "queued" && "bg-yellow-500 animate-pulse"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PopoverPanel
|
<PopoverPanel
|
||||||
anchor="bottom start"
|
anchor="bottom end"
|
||||||
transition
|
transition
|
||||||
className={cx(
|
className={cx(
|
||||||
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
"z-10 flex w-[380px] origin-top flex-col overflow-visible!",
|
||||||
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
checkIfStateChanged(open);
|
checkIfStateChanged(open);
|
||||||
return (
|
return <SessionPopover />;
|
||||||
<div className="mx-auto w-full max-w-xl">
|
|
||||||
<MountPopopover />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
|
{hasPermission(Permission.EXTENSION_MANAGE) && (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Wake on LAN"
|
text="Extension"
|
||||||
|
LeadingIcon={LuCable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableVideoFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
LeadingIcon={({ className }) => (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
|
|
||||||
<path d="M6 8v1" />
|
|
||||||
<path d="M10 8v1" />
|
|
||||||
<path d="M14 8v1" />
|
|
||||||
<path d="M18 8v1" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
<PopoverPanel
|
<PopoverPanel
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
transition
|
transition
|
||||||
style={{
|
|
||||||
transitionProperty: "opacity",
|
|
||||||
}}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
"z-10 flex w-[420px] flex-col overflow-visible!",
|
||||||
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
checkIfStateChanged(open);
|
checkIfStateChanged(open);
|
||||||
return (
|
return <ExtensionPopover />;
|
||||||
<div className="mx-auto w-full max-w-xl">
|
|
||||||
<WakeOnLanModal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
)}
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Button
|
|
||||||
size="XS"
|
|
||||||
theme="light"
|
|
||||||
text="Virtual Keyboard"
|
|
||||||
LeadingIcon={FaKeyboard}
|
|
||||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
||||||
<Popover>
|
<div className="block lg:hidden">
|
||||||
<PopoverButton as={Fragment}>
|
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Extension"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={LuCable}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => {
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
setDisableVideoFocusTrap(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</div>
|
||||||
<PopoverPanel
|
)}
|
||||||
anchor="bottom start"
|
|
||||||
transition
|
|
||||||
className={cx(
|
|
||||||
"z-10 flex w-[420px] flex-col overflow-visible!",
|
|
||||||
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ open }) => {
|
|
||||||
checkIfStateChanged(open);
|
|
||||||
return <ExtensionPopover />;
|
|
||||||
}}
|
|
||||||
</PopoverPanel>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<div className="block lg:hidden">
|
|
||||||
<Button
|
|
||||||
size="XS"
|
|
||||||
theme="light"
|
|
||||||
text="Virtual Keyboard"
|
|
||||||
LeadingIcon={FaKeyboard}
|
|
||||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
@ -258,18 +355,21 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* Only show Settings for sessions with settings access */}
|
||||||
<Button
|
{hasPermission(Permission.SETTINGS_ACCESS) && (
|
||||||
size="XS"
|
<div>
|
||||||
theme="light"
|
<Button
|
||||||
text="Settings"
|
size="XS"
|
||||||
LeadingIcon={LuSettings}
|
theme="light"
|
||||||
onClick={() => {
|
text="Settings"
|
||||||
setDisableVideoFocusTrap(true);
|
LeadingIcon={LuSettings}
|
||||||
navigateTo("/settings")
|
onClick={() => {
|
||||||
}}
|
setDisableVideoFocusTrap(true);
|
||||||
/>
|
navigateTo("/settings")
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden items-center gap-x-2 lg:flex">
|
<div className="hidden items-center gap-x-2 lg:flex">
|
||||||
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import USBStateStatus from "@components/USBStateStatus";
|
import USBStateStatus from "@components/USBStateStatus";
|
||||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
|
@ -37,6 +38,8 @@ export default function DashboardNavbar({
|
||||||
}: NavbarProps) {
|
}: NavbarProps) {
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const setUser = useUserStore(state => state.setUser);
|
const setUser = useUserStore(state => state.setUser);
|
||||||
|
const { clearSession } = useSessionStore();
|
||||||
|
const { clearNickname } = useSharedSessionStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onLogout = useCallback(async () => {
|
const onLogout = useCallback(async () => {
|
||||||
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||||
|
|
@ -44,9 +47,12 @@ export default function DashboardNavbar({
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
// Clear the stored session data via zustand
|
||||||
|
clearNickname();
|
||||||
|
clearSession();
|
||||||
// The root route will redirect to appropriate login page, be it the local one or the cloud one
|
// The root route will redirect to appropriate login page, be it the local one or the cloud one
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}, [navigate, setUser]);
|
}, [navigate, setUser, clearNickname, clearSession]);
|
||||||
|
|
||||||
const { usbState } = useHidStore();
|
const { usbState } = useHidStore();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,26 @@ import Container from "@components/Container";
|
||||||
import { useMacrosStore } from "@/hooks/stores";
|
import { useMacrosStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
export default function MacroBar() {
|
export default function MacroBar() {
|
||||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||||
const { executeMacro } = useKeyboard();
|
const { executeMacro } = useKeyboard();
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
const { permissions, hasPermission } = usePermissions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSendFn(send);
|
setSendFn(send);
|
||||||
|
|
||||||
if (!initialized) {
|
// Only load macros if user has permission to read settings
|
||||||
|
if (!initialized && permissions[Permission.SETTINGS_READ] === true) {
|
||||||
loadMacros();
|
loadMacros();
|
||||||
}
|
}
|
||||||
}, [initialized, loadMacros, setSendFn, send]);
|
}, [initialized, send, loadMacros, setSendFn, permissions]);
|
||||||
|
|
||||||
if (macros.length === 0) {
|
// Don't show macros if user can't provide keyboard input or if no macros exist
|
||||||
|
if (macros.length === 0 || !hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Dialog, DialogPanel, DialogBackdrop } from "@headlessui/react";
|
||||||
|
import { UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
|
import { useSettingsStore , useRTCStore } from "@/hooks/stores";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { generateNickname } from "@/utils/nicknameGenerator";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
type SessionRole = "primary" | "observer" | "queued" | "pending";
|
||||||
|
|
||||||
|
interface NicknameModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onSubmit: (nickname: string) => void | Promise<void>;
|
||||||
|
onSkip?: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
expectedRole?: SessionRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NicknameModal({
|
||||||
|
isOpen,
|
||||||
|
onSubmit,
|
||||||
|
onSkip,
|
||||||
|
title = "Set Your Session Nickname",
|
||||||
|
description = "Add a nickname to help identify your session to other users",
|
||||||
|
isRequired,
|
||||||
|
expectedRole = "observer"
|
||||||
|
}: NicknameModalProps) {
|
||||||
|
const [nickname, setNickname] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [generatedNickname, setGeneratedNickname] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { requireSessionNickname } = useSettingsStore();
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
|
||||||
|
const isNicknameRequired = isRequired ?? requireSessionNickname;
|
||||||
|
|
||||||
|
// Role-based color coding
|
||||||
|
const getRoleColors = (role: SessionRole) => {
|
||||||
|
switch (role) {
|
||||||
|
case "primary":
|
||||||
|
return {
|
||||||
|
bg: "bg-green-100 dark:bg-green-900/30",
|
||||||
|
icon: "text-green-600 dark:text-green-400"
|
||||||
|
};
|
||||||
|
case "observer":
|
||||||
|
return {
|
||||||
|
bg: "bg-blue-100 dark:bg-blue-900/30",
|
||||||
|
icon: "text-blue-600 dark:text-blue-400"
|
||||||
|
};
|
||||||
|
case "queued":
|
||||||
|
return {
|
||||||
|
bg: "bg-yellow-100 dark:bg-yellow-900/30",
|
||||||
|
icon: "text-yellow-600 dark:text-yellow-400"
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
bg: "bg-orange-100 dark:bg-orange-900/30",
|
||||||
|
icon: "text-orange-600 dark:text-orange-400"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: "bg-slate-100 dark:bg-slate-900/30",
|
||||||
|
icon: "text-slate-600 dark:text-slate-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleColors = getRoleColors(expectedRole);
|
||||||
|
|
||||||
|
// Generate nickname when modal opens and RPC is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || generatedNickname) return;
|
||||||
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
|
||||||
|
generateNickname(send).then(nickname => {
|
||||||
|
setGeneratedNickname(nickname);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Backend nickname generation failed:', error);
|
||||||
|
});
|
||||||
|
}, [isOpen, generatedNickname, rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
|
// Focus input when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const validateNickname = (value: string): string | null => {
|
||||||
|
if (value.length < 2) {
|
||||||
|
return "Nickname must be at least 2 characters";
|
||||||
|
}
|
||||||
|
if (value.length > 30) {
|
||||||
|
return "Nickname must be 30 characters or less";
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9\s\-_.@]+$/.test(value)) {
|
||||||
|
return "Nickname can only contain letters, numbers, spaces, and - _ . @";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
|
||||||
|
// Use generated nickname if input is empty
|
||||||
|
const trimmedNickname = nickname.trim() || generatedNickname;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const validationError = validateNickname(trimmedNickname);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(trimmedNickname);
|
||||||
|
setNickname("");
|
||||||
|
setGeneratedNickname(""); // Reset generated nickname after successful submit
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : "Failed to set nickname");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
if (!isNicknameRequired && onSkip) {
|
||||||
|
onSkip();
|
||||||
|
setNickname("");
|
||||||
|
setError(null);
|
||||||
|
setGeneratedNickname(""); // Reset generated nickname when skipping
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isNicknameRequired && onSkip) {
|
||||||
|
onSkip();
|
||||||
|
setNickname("");
|
||||||
|
setError(null);
|
||||||
|
setGeneratedNickname("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative z-50"
|
||||||
|
>
|
||||||
|
<DialogBackdrop className="fixed inset-0 bg-black/50" />
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<DialogPanel className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 ${roleColors.bg} rounded-lg`}>
|
||||||
|
<UserIcon className={`h-6 w-6 ${roleColors.icon}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isNicknameRequired && (
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5 text-slate-500 dark:text-slate-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="nickname" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
Nickname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="nickname"
|
||||||
|
type="text"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNickname(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={generatedNickname || "e.g., John's Laptop, Office PC, etc."}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md
|
||||||
|
bg-white dark:bg-slate-700 text-slate-900 dark:text-white
|
||||||
|
placeholder-slate-400 dark:placeholder-slate-500
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between items-center">
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{nickname.trim() === "" && generatedNickname
|
||||||
|
? `Leave empty to use: ${generatedNickname}`
|
||||||
|
: "2-30 characters, letters, numbers, spaces, and - _ . @ allowed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{nickname.length}/30
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNicknameRequired && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<strong>Required:</strong> A nickname is required by the administrator to help identify sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
theme="primary"
|
||||||
|
size="MD"
|
||||||
|
text="Set Nickname"
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{!isNicknameRequired && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
theme="light"
|
||||||
|
size="MD"
|
||||||
|
text="Skip"
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ClockIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
interface PendingApprovalOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingApprovalOverlay({ show }: PendingApprovalOverlayProps) {
|
||||||
|
const [dots, setDots] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setDots(prev => (prev.length >= 3 ? "" : prev + "."));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<ClockIcon className="h-12 w-12 text-amber-500 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Awaiting Approval{dots}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Your session is pending approval from the primary session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 w-full">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300 text-center">
|
||||||
|
The primary user will receive a notification to approve or deny your access.
|
||||||
|
This typically takes less than 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="h-2 w-2 bg-amber-500 rounded-full animate-pulse" />
|
||||||
|
<span>Waiting for response from primary session</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import {
|
||||||
|
LockClosedIcon,
|
||||||
|
LockOpenIcon,
|
||||||
|
ClockIcon
|
||||||
|
} from "@heroicons/react/16/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
interface SessionControlPanelProps {
|
||||||
|
sendFn: RpcSendFunction;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionControlPanel({ sendFn, className }: SessionControlPanelProps) {
|
||||||
|
const {
|
||||||
|
currentSessionId,
|
||||||
|
currentMode,
|
||||||
|
sessions,
|
||||||
|
isRequestingPrimary,
|
||||||
|
setRequestingPrimary,
|
||||||
|
setSessionError,
|
||||||
|
canRequestPrimary
|
||||||
|
} = useSessionStore();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
|
||||||
|
const handleRequestPrimary = async () => {
|
||||||
|
if (!currentSessionId || isRequestingPrimary) return;
|
||||||
|
|
||||||
|
setRequestingPrimary(true);
|
||||||
|
setSessionError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sessionApi.requestPrimary(sendFn, currentSessionId);
|
||||||
|
|
||||||
|
if (result.status === "success") {
|
||||||
|
if (result.mode === "primary") {
|
||||||
|
// Immediately became primary
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
} else if (result.mode === "queued") {
|
||||||
|
// Request sent, waiting for approval
|
||||||
|
// Keep isRequestingPrimary true to show waiting state
|
||||||
|
}
|
||||||
|
} else if (result.status === "error") {
|
||||||
|
setSessionError(result.message || "Failed to request primary control");
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSessionError(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
console.error("Failed to request primary control:", error);
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReleasePrimary = async () => {
|
||||||
|
if (!currentSessionId || currentMode !== "primary") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionApi.releasePrimary(sendFn, currentSessionId);
|
||||||
|
} catch (error) {
|
||||||
|
setSessionError(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
console.error("Failed to release primary control:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReleasePrimary = () => {
|
||||||
|
const otherEligibleSessions = sessions.filter(
|
||||||
|
s => s.id !== currentSessionId && (s.mode === "observer" || s.mode === "queued")
|
||||||
|
);
|
||||||
|
return otherEligibleSessions.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("space-y-4", className)}>
|
||||||
|
{/* Current session controls */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
Session Control
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{hasPermission(Permission.SESSION_RELEASE_PRIMARY) && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="light"
|
||||||
|
text="Release Primary Control"
|
||||||
|
onClick={handleReleasePrimary}
|
||||||
|
disabled={!canReleasePrimary()}
|
||||||
|
LeadingIcon={LockOpenIcon}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{!canReleasePrimary() && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Cannot release control - no other sessions available to take primary
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission(Permission.SESSION_REQUEST_PRIMARY) && (
|
||||||
|
<>
|
||||||
|
{isRequestingPrimary ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<ClockIcon className="h-5 w-5 text-blue-600 dark:text-blue-400 animate-pulse" />
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Waiting for approval from primary session...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
text="Request Primary Control"
|
||||||
|
onClick={handleRequestPrimary}
|
||||||
|
disabled={!canRequestPrimary()}
|
||||||
|
LeadingIcon={LockClosedIcon}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentMode === "queued" && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20">
|
||||||
|
<ClockIcon className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
Waiting for primary control...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { formatters } from "@/utils";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
mode: string;
|
||||||
|
nickname?: string;
|
||||||
|
identity?: string;
|
||||||
|
source?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
currentSessionId?: string;
|
||||||
|
onEditNickname?: (sessionId: string) => void;
|
||||||
|
onApprove?: (sessionId: string) => void;
|
||||||
|
onDeny?: (sessionId: string) => void;
|
||||||
|
onTransfer?: (sessionId: string) => void;
|
||||||
|
formatDuration?: (createdAt: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionsList({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
onEditNickname,
|
||||||
|
onApprove,
|
||||||
|
onDeny,
|
||||||
|
onTransfer,
|
||||||
|
formatDuration = (createdAt: string) => formatters.timeAgo(new Date(createdAt)) || ""
|
||||||
|
}: SessionsListProps) {
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={clsx(
|
||||||
|
"p-2 rounded-md border text-xs",
|
||||||
|
session.id === currentSessionId
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/10"
|
||||||
|
: session.mode === "pending"
|
||||||
|
? "border-orange-300 dark:border-orange-800/50 bg-orange-50/50 dark:bg-orange-900/10"
|
||||||
|
: "border-slate-200 dark:border-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SessionModeBadge mode={session.mode} />
|
||||||
|
{session.id === currentSessionId && (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400 font-medium">(You)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
|
{session.createdAt ? formatDuration(session.createdAt) : ""}
|
||||||
|
</span>
|
||||||
|
{/* Show approve/deny for pending sessions if user has permission */}
|
||||||
|
{session.mode === "pending" && hasPermission(Permission.SESSION_APPROVE) && onApprove && onDeny && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onApprove(session.id)}
|
||||||
|
className="p-1 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||||
|
title="Approve session"
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeny(session.id)}
|
||||||
|
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||||
|
title="Deny session"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Show Transfer button if user has permission to transfer */}
|
||||||
|
{hasPermission(Permission.SESSION_TRANSFER) && session.mode === "observer" && session.id !== currentSessionId && onTransfer && (
|
||||||
|
<button
|
||||||
|
onClick={() => onTransfer(session.id)}
|
||||||
|
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-400 transition-colors"
|
||||||
|
title="Transfer primary control"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Allow users with session manage permission to edit any nickname, or anyone to edit their own */}
|
||||||
|
{onEditNickname && (hasPermission(Permission.SESSION_MANAGE) || session.id === currentSessionId) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEditNickname(session.id)}
|
||||||
|
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Edit nickname"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-3 w-3 text-slate-500 dark:text-slate-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{session.nickname && (
|
||||||
|
<p className="text-slate-700 dark:text-slate-200 font-medium">
|
||||||
|
{session.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.identity && (
|
||||||
|
<p className="text-slate-600 dark:text-slate-300 text-xs">
|
||||||
|
{session.source === "cloud" ? "☁️ " : ""}{session.identity}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.mode === "pending" && (
|
||||||
|
<p className="text-orange-600 dark:text-orange-400 text-xs italic">
|
||||||
|
Awaiting approval
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionModeBadge({ mode }: { mode: string }) {
|
||||||
|
const getBadgeStyle = () => {
|
||||||
|
switch (mode) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
|
||||||
|
case "observer":
|
||||||
|
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
|
||||||
|
case "queued":
|
||||||
|
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
|
||||||
|
case "pending":
|
||||||
|
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 text-xs font-medium rounded-full",
|
||||||
|
getBadgeStyle()
|
||||||
|
)}>
|
||||||
|
{mode}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { XMarkIcon, UserIcon, GlobeAltIcon, ComputerDesktopIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
type RequestType = "session_approval" | "primary_control";
|
||||||
|
|
||||||
|
interface UnifiedSessionRequest {
|
||||||
|
id: string; // sessionId or requestId
|
||||||
|
type: RequestType;
|
||||||
|
source: "local" | "cloud" | string; // Allow string for IP addresses
|
||||||
|
identity?: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedSessionRequestDialogProps {
|
||||||
|
request: UnifiedSessionRequest | null;
|
||||||
|
onApprove: (id: string) => void | Promise<void>;
|
||||||
|
onDeny: (id: string) => void | Promise<void>;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnifiedSessionRequestDialog({
|
||||||
|
request,
|
||||||
|
onApprove,
|
||||||
|
onDeny,
|
||||||
|
onDismiss,
|
||||||
|
onClose
|
||||||
|
}: UnifiedSessionRequestDialogProps) {
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(0);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
const isSessionApproval = request.type === "session_approval";
|
||||||
|
const initialTime = isSessionApproval ? 60 : 0; // 60s for session approval, no timeout for primary control
|
||||||
|
|
||||||
|
setTimeRemaining(initialTime);
|
||||||
|
setIsProcessing(false);
|
||||||
|
setHasTimedOut(false);
|
||||||
|
|
||||||
|
// Only start timer for session approval requests
|
||||||
|
if (isSessionApproval) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTimeRemaining(prev => {
|
||||||
|
const newTime = prev - 1;
|
||||||
|
if (newTime <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
setHasTimedOut(true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [request?.id, request?.type]); // Only depend on stable properties to avoid unnecessary re-renders
|
||||||
|
|
||||||
|
// Handle auto-deny when timeout occurs
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasTimedOut && !isProcessing && request) {
|
||||||
|
setIsProcessing(true);
|
||||||
|
Promise.resolve(onDeny(request.id))
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Failed to auto-deny request:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [hasTimedOut, isProcessing, request, onDeny, onClose]);
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
const isSessionApproval = request.type === "session_approval";
|
||||||
|
const isPrimaryControl = request.type === "primary_control";
|
||||||
|
|
||||||
|
// Determine if source is cloud, local, or IP address
|
||||||
|
const getSourceInfo = () => {
|
||||||
|
if (request.source === "cloud") {
|
||||||
|
return {
|
||||||
|
type: "cloud",
|
||||||
|
label: "Cloud Session",
|
||||||
|
icon: GlobeAltIcon,
|
||||||
|
iconColor: "text-blue-500"
|
||||||
|
};
|
||||||
|
} else if (request.source === "local") {
|
||||||
|
return {
|
||||||
|
type: "local",
|
||||||
|
label: "Local Session",
|
||||||
|
icon: ComputerDesktopIcon,
|
||||||
|
iconColor: "text-green-500"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Assume it's an IP address or hostname
|
||||||
|
return {
|
||||||
|
type: "ip",
|
||||||
|
label: request.source,
|
||||||
|
icon: ComputerDesktopIcon,
|
||||||
|
iconColor: "text-green-500"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceInfo = getSourceInfo();
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (isSessionApproval) return "New Session Request";
|
||||||
|
if (isPrimaryControl) return "Primary Control Request";
|
||||||
|
return "Session Request";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDescription = () => {
|
||||||
|
if (isSessionApproval) return "A new session is attempting to connect to this device:";
|
||||||
|
if (isPrimaryControl) return "A user is requesting primary control of this session:";
|
||||||
|
return "A user is making a request:";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{getTitle()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<p className="text-slate-700 dark:text-slate-300">
|
||||||
|
{getDescription()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-2">
|
||||||
|
{/* Session type - always show with icon for both session approval and primary control */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<sourceInfo.icon className={`h-5 w-5 ${sourceInfo.iconColor}`} />
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{sourceInfo.type === "cloud" ? "Cloud Session" :
|
||||||
|
sourceInfo.type === "local" ? "Local Session" :
|
||||||
|
`Local Session`}
|
||||||
|
</span>
|
||||||
|
{sourceInfo.type === "ip" && (
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
({sourceInfo.label})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nickname - always show with icon for consistency */}
|
||||||
|
{request.nickname && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="h-5 w-5 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<span className="font-medium text-slate-600 dark:text-slate-400">Nickname:</span>{" "}
|
||||||
|
<span className="font-medium text-slate-900 dark:text-white">{request.nickname}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Identity/User */}
|
||||||
|
{request.identity && (
|
||||||
|
<div className={`text-sm ${isSessionApproval ? 'text-slate-600 dark:text-slate-400' : ''}`}>
|
||||||
|
{isSessionApproval ? (
|
||||||
|
<p>Identity: {request.identity}</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-600 dark:text-slate-400">User:</span>{" "}
|
||||||
|
<span className="text-slate-900 dark:text-white">{request.identity}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Note - only for session approval */}
|
||||||
|
{isSessionApproval && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<strong>Security Note:</strong> Only approve sessions you recognize.
|
||||||
|
Approved sessions will have observer access and can request primary control.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-deny timer - only for session approval */}
|
||||||
|
{isSessionApproval && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Auto-deny in <span className="font-mono font-bold">{timeRemaining}</span> seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await onApprove(request.id);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
theme="primary"
|
||||||
|
size="MD"
|
||||||
|
text="Approve"
|
||||||
|
fullWidth
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await onDeny(request.id);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to deny request:", error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
theme="light"
|
||||||
|
size="MD"
|
||||||
|
text="Deny"
|
||||||
|
fullWidth
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onDismiss();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
theme="light"
|
||||||
|
size="MD"
|
||||||
|
text="Dismiss (Hide Request)"
|
||||||
|
fullWidth
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import useMouse from "@/hooks/useMouse";
|
import useMouse from "@/hooks/useMouse";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +37,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
|
|
||||||
// Store hooks
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
||||||
const {
|
const {
|
||||||
getRelMouseMoveHandler,
|
getRelMouseMoveHandler,
|
||||||
|
|
@ -214,29 +217,47 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
}, [releaseKeyboardLock]);
|
}, [releaseKeyboardLock]);
|
||||||
|
|
||||||
const absMouseMoveHandler = useMemo(
|
const absMouseMoveHandler = useMemo(() => {
|
||||||
() => getAbsMouseMoveHandler({
|
const handler = getAbsMouseMoveHandler({
|
||||||
videoClientWidth,
|
videoClientWidth,
|
||||||
videoClientHeight,
|
videoClientHeight,
|
||||||
videoWidth,
|
videoWidth,
|
||||||
videoHeight,
|
videoHeight,
|
||||||
}),
|
});
|
||||||
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight],
|
return (e: MouseEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight, hasPermission]);
|
||||||
|
|
||||||
const relMouseMoveHandler = useMemo(
|
const relMouseMoveHandler = useMemo(() => {
|
||||||
() => getRelMouseMoveHandler(),
|
const handler = getRelMouseMoveHandler();
|
||||||
[getRelMouseMoveHandler],
|
return (e: MouseEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [getRelMouseMoveHandler, hasPermission]);
|
||||||
|
|
||||||
const mouseWheelHandler = useMemo(
|
const mouseWheelHandler = useMemo(() => {
|
||||||
() => getMouseWheelHandler(),
|
const handler = getMouseWheelHandler();
|
||||||
[getMouseWheelHandler],
|
return (e: WheelEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [getMouseWheelHandler, hasPermission]);
|
||||||
|
|
||||||
const keyDownHandler = useCallback(
|
const keyDownHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only allow input if user has keyboard permission
|
||||||
|
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
const code = getAdjustedKeyCode(e);
|
const code = getAdjustedKeyCode(e);
|
||||||
const hidKey = keys[code];
|
const hidKey = keys[code];
|
||||||
|
|
@ -252,11 +273,9 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
||||||
if (e.metaKey && hidKey < 0xE0) {
|
if (e.metaKey && hidKey < 0xE0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
console.debug(`Key down: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, true);
|
handleKeyPress(hidKey, true);
|
||||||
|
|
||||||
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
||||||
|
|
@ -264,17 +283,22 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
// we'll never see the keyup event because the browser is going to lose
|
// we'll never see the keyup event because the browser is going to lose
|
||||||
// focus so set a deferred keyup after a short delay
|
// focus so set a deferred keyup after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.debug(`Forcing the left meta key release`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleKeyPress, isKeyboardLockActive],
|
[handleKeyPress, isKeyboardLockActive, hasPermission],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyUpHandler = useCallback(
|
const keyUpHandler = useCallback(
|
||||||
async (e: KeyboardEvent) => {
|
async (e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only allow input if user has keyboard permission
|
||||||
|
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const code = getAdjustedKeyCode(e);
|
const code = getAdjustedKeyCode(e);
|
||||||
const hidKey = keys[code];
|
const hidKey = keys[code];
|
||||||
|
|
||||||
|
|
@ -283,10 +307,9 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`Key up: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
},
|
},
|
||||||
[handleKeyPress],
|
[handleKeyPress, hasPermission],
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
|
@ -297,7 +320,6 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
// Fix only works in chrome based browsers.
|
// Fix only works in chrome based browsers.
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") {
|
||||||
if (videoElm.current.paused) {
|
if (videoElm.current.paused) {
|
||||||
console.debug("Force playing video");
|
|
||||||
videoElm.current.play();
|
videoElm.current.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +579,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
{hasPermission(Permission.KEYBOARD_INPUT) && <VirtualKeyboard />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
UserGroupIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
PencilIcon,
|
||||||
|
} from "@heroicons/react/20/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import SessionControlPanel from "@/components/SessionControlPanel";
|
||||||
|
import NicknameModal from "@/components/NicknameModal";
|
||||||
|
import SessionsList, { SessionModeBadge } from "@/components/SessionsList";
|
||||||
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
|
|
||||||
|
export default function SessionPopover() {
|
||||||
|
const {
|
||||||
|
currentSessionId,
|
||||||
|
currentMode,
|
||||||
|
sessions,
|
||||||
|
sessionError,
|
||||||
|
setSessions,
|
||||||
|
} = useSessionStore();
|
||||||
|
const { setNickname } = useSharedSessionStore();
|
||||||
|
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [showNicknameModal, setShowNicknameModal] = useState(false);
|
||||||
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
// Adapter function to match existing callback pattern
|
||||||
|
const sendRpc = useCallback((method: string, params: Record<string, unknown>, callback?: (response: { result?: unknown; error?: { message: string } }) => void) => {
|
||||||
|
send(method, params, (response) => {
|
||||||
|
if (callback) callback(response);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (isRefreshing) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const refreshedSessions = await sessionApi.getSessions(sendRpc);
|
||||||
|
setSessions(refreshedSessions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh sessions:", error);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch sessions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
sessionApi.getSessions(sendRpc)
|
||||||
|
.then(sessions => setSessions(sessions))
|
||||||
|
.catch(error => console.error("Failed to fetch sessions:", error));
|
||||||
|
}
|
||||||
|
}, [sendRpc, sessions.length, setSessions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-lg bg-white dark:bg-slate-800 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserGroupIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
Session Management
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className={clsx("h-4 w-4 text-slate-600 dark:text-slate-400", isRefreshing && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session Error */}
|
||||||
|
{sessionError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/10 border-b border-red-200 dark:border-red-800">
|
||||||
|
<p className="text-xs text-red-700 dark:text-red-400">{sessionError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Session */}
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Your Session</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingSessionId(currentSessionId);
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Edit nickname"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-3 w-3 text-slate-500 dark:text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SessionModeBadge mode={currentMode || "unknown"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSessionId && (
|
||||||
|
<>
|
||||||
|
{/* Display current session nickname if exists */}
|
||||||
|
{sessions.find(s => s.id === currentSessionId)?.nickname && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Nickname:</span>
|
||||||
|
<span className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{sessions.find(s => s.id === currentSessionId)?.nickname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<SessionControlPanel sendFn={sendRpc} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Sessions List */}
|
||||||
|
<div className="p-4 max-h-64 overflow-y-auto">
|
||||||
|
<div className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
Active Sessions ({sessions.length})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.length > 0 ? (
|
||||||
|
<SessionsList
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId || undefined}
|
||||||
|
onEditNickname={(sessionId) => {
|
||||||
|
setEditingSessionId(sessionId);
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
}}
|
||||||
|
onApprove={(sessionId) => {
|
||||||
|
sendRpc("approveNewSession", { sessionId }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to approve session:", response.error);
|
||||||
|
} else {
|
||||||
|
handleRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeny={(sessionId) => {
|
||||||
|
sendRpc("denyNewSession", { sessionId }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to deny session:", response.error);
|
||||||
|
} else {
|
||||||
|
handleRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onTransfer={async (sessionId) => {
|
||||||
|
try {
|
||||||
|
await sessionApi.transferPrimary(sendRpc, currentSessionId!, sessionId);
|
||||||
|
handleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to transfer primary:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">No active sessions</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NicknameModal
|
||||||
|
isOpen={showNicknameModal}
|
||||||
|
title={editingSessionId === currentSessionId
|
||||||
|
? (sessions.find(s => s.id === currentSessionId)?.nickname ? "Update Your Nickname" : "Set Your Nickname")
|
||||||
|
: `Set Nickname for ${sessions.find(s => s.id === editingSessionId)?.mode || 'Session'}`}
|
||||||
|
description={editingSessionId === currentSessionId
|
||||||
|
? "Choose a nickname to help identify your session to others"
|
||||||
|
: "Choose a nickname to help identify this session"}
|
||||||
|
onSubmit={async (nickname) => {
|
||||||
|
if (editingSessionId && sendRpc) {
|
||||||
|
try {
|
||||||
|
await sessionApi.updateNickname(sendRpc, editingSessionId, nickname);
|
||||||
|
if (editingSessionId === currentSessionId) {
|
||||||
|
setNickname(nickname);
|
||||||
|
}
|
||||||
|
setShowNicknameModal(false);
|
||||||
|
setEditingSessionId(null);
|
||||||
|
handleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update nickname:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSkip={() => {
|
||||||
|
setShowNicknameModal(false);
|
||||||
|
setEditingSessionId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
import { PermissionsContextValue } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
|
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);
|
||||||
|
|
@ -343,6 +343,15 @@ export interface SettingsState {
|
||||||
developerMode: boolean;
|
developerMode: boolean;
|
||||||
setDeveloperMode: (enabled: boolean) => void;
|
setDeveloperMode: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
requireSessionNickname: boolean;
|
||||||
|
setRequireSessionNickname: (required: boolean) => void;
|
||||||
|
|
||||||
|
requireSessionApproval: boolean;
|
||||||
|
setRequireSessionApproval: (required: boolean) => void;
|
||||||
|
|
||||||
|
maxRejectionAttempts: number;
|
||||||
|
setMaxRejectionAttempts: (attempts: number) => void;
|
||||||
|
|
||||||
displayRotation: string;
|
displayRotation: string;
|
||||||
setDisplayRotation: (rotation: string) => void;
|
setDisplayRotation: (rotation: string) => void;
|
||||||
|
|
||||||
|
|
@ -383,6 +392,15 @@ export const useSettingsStore = create(
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
|
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
|
||||||
|
|
||||||
|
requireSessionNickname: false,
|
||||||
|
setRequireSessionNickname: (required: boolean) => set({ requireSessionNickname: required }),
|
||||||
|
|
||||||
|
requireSessionApproval: true,
|
||||||
|
setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }),
|
||||||
|
|
||||||
|
maxRejectionAttempts: 3,
|
||||||
|
setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }),
|
||||||
|
|
||||||
displayRotation: "270",
|
displayRotation: "270",
|
||||||
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
|
@ -36,6 +36,12 @@ let requestCounter = 0;
|
||||||
|
|
||||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const onRequestRef = useRef(onRequest);
|
||||||
|
|
||||||
|
// Update ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
onRequestRef.current = onRequest;
|
||||||
|
}, [onRequest]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||||
|
|
@ -59,7 +65,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
// The "API" can also "request" data from the client
|
// The "API" can also "request" data from the client
|
||||||
// If the payload has a method, it's a request
|
// If the payload has a method, it's a request
|
||||||
if ("method" in payload) {
|
if ("method" in payload) {
|
||||||
if (onRequest) onRequest(payload);
|
if (onRequestRef.current) onRequestRef.current(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +85,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
rpcDataChannel.removeEventListener("message", messageHandler);
|
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[rpcDataChannel, onRequest]);
|
[rpcDataChannel]); // Remove onRequest from dependencies
|
||||||
|
|
||||||
return { send };
|
return { send };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { PermissionsContext } from "@/contexts/PermissionsContext";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
|
export interface PermissionsContextValue {
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (permission: Permission) => boolean;
|
||||||
|
hasAnyPermission: (...perms: Permission[]) => boolean;
|
||||||
|
hasAllPermissions: (...perms: Permission[]) => boolean;
|
||||||
|
isPrimary: () => boolean;
|
||||||
|
isObserver: () => boolean;
|
||||||
|
isPending: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions(): PermissionsContextValue {
|
||||||
|
const context = useContext(PermissionsContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
return {
|
||||||
|
permissions: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasPermission: () => false,
|
||||||
|
hasAnyPermission: () => false,
|
||||||
|
hasAllPermissions: () => false,
|
||||||
|
isPrimary: () => false,
|
||||||
|
isObserver: () => false,
|
||||||
|
isPending: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useSessionStore, SessionInfo } from "@/stores/sessionStore";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
|
import { notify } from "@/notifications";
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
interface SessionEventData {
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
yourMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeChangedData {
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionModeChangedData {
|
||||||
|
newMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
|
const {
|
||||||
|
currentMode,
|
||||||
|
setSessions,
|
||||||
|
updateSessionMode,
|
||||||
|
setSessionError
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const sendFnRef = useRef(sendFn);
|
||||||
|
sendFnRef.current = sendFn;
|
||||||
|
|
||||||
|
const handleSessionEvent = (method: string, params: unknown) => {
|
||||||
|
switch (method) {
|
||||||
|
case "sessionsUpdated":
|
||||||
|
handleSessionsUpdated(params as SessionEventData);
|
||||||
|
break;
|
||||||
|
case "modeChanged":
|
||||||
|
handleModeChanged(params as ModeChangedData);
|
||||||
|
break;
|
||||||
|
case "connectionModeChanged":
|
||||||
|
handleConnectionModeChanged(params as ConnectionModeChangedData);
|
||||||
|
break;
|
||||||
|
case "hidReadyForPrimary":
|
||||||
|
handleHidReadyForPrimary();
|
||||||
|
break;
|
||||||
|
case "otherSessionConnected":
|
||||||
|
handleOtherSessionConnected();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionsUpdated = (data: SessionEventData) => {
|
||||||
|
if (data.sessions) {
|
||||||
|
setSessions(data.sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Only update mode, never show notifications from sessionsUpdated
|
||||||
|
// Notifications are exclusively handled by handleModeChanged to prevent duplicates
|
||||||
|
if (data.yourMode && data.yourMode !== currentMode) {
|
||||||
|
updateSessionMode(data.yourMode as "primary" | "observer" | "queued" | "pending");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce notifications to prevent rapid-fire duplicates
|
||||||
|
const lastNotificationRef = useRef<{mode: string, timestamp: number}>({mode: "", timestamp: 0});
|
||||||
|
|
||||||
|
const handleModeChanged = (data: ModeChangedData) => {
|
||||||
|
if (data.mode) {
|
||||||
|
// Get the most current mode from the store to avoid race conditions
|
||||||
|
const { currentMode: currentModeFromStore } = useSessionStore.getState();
|
||||||
|
const previousMode = currentModeFromStore;
|
||||||
|
updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending");
|
||||||
|
|
||||||
|
if (previousMode === "queued" && data.mode !== "queued") {
|
||||||
|
const { setRequestingPrimary } = useSessionStore.getState();
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousMode === "pending" && data.mode === "observer") {
|
||||||
|
const { resetRejectionCount } = useSessionStore.getState();
|
||||||
|
resetRejectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// HID re-initialization is now handled automatically by permission changes in usePermissions
|
||||||
|
|
||||||
|
// CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events
|
||||||
|
const now = Date.now();
|
||||||
|
const lastNotification = lastNotificationRef.current;
|
||||||
|
|
||||||
|
// Only show notification if:
|
||||||
|
// 1. Mode actually changed, AND
|
||||||
|
// 2. Haven't shown the same notification in the last 2 seconds
|
||||||
|
const shouldNotify = previousMode !== data.mode &&
|
||||||
|
(lastNotification.mode !== data.mode || now - lastNotification.timestamp > 2000);
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
if (data.mode === "primary") {
|
||||||
|
notify.success("Primary control granted");
|
||||||
|
lastNotificationRef.current = {mode: "primary", timestamp: now};
|
||||||
|
} else if (data.mode === "observer" && previousMode === "primary") {
|
||||||
|
notify.info("Primary control released");
|
||||||
|
lastNotificationRef.current = {mode: "observer", timestamp: now};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectionModeChanged = (data: ConnectionModeChangedData) => {
|
||||||
|
if (data.newMode) {
|
||||||
|
handleModeChanged({ mode: data.newMode });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHidReadyForPrimary = () => {
|
||||||
|
const { rpcHidChannel } = useRTCStore.getState();
|
||||||
|
if (rpcHidChannel?.readyState === "open") {
|
||||||
|
rpcHidChannel.dispatchEvent(new Event("open"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtherSessionConnected = () => {
|
||||||
|
notify.warning("Another session is connecting", {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sendFnRef.current) return;
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
try {
|
||||||
|
const sessions = await sessionApi.getSessions(sendFnRef.current!);
|
||||||
|
setSessions(sessions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch sessions:", error);
|
||||||
|
setSessionError("Failed to fetch session information");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSessions();
|
||||||
|
}, [setSessions, setSessionError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sendFnRef.current) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
if (!sendFnRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await sessionApi.getSessions(sendFnRef.current);
|
||||||
|
setSessions(sessions);
|
||||||
|
} catch {
|
||||||
|
// Silently fail on refresh errors
|
||||||
|
}
|
||||||
|
}, 30000); // Refresh every 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [setSessions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSessionEvent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useEffect, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useSessionEvents } from "@/hooks/useSessionEvents";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
interface SessionResponse {
|
||||||
|
sessionId?: string;
|
||||||
|
mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrimaryControlRequest {
|
||||||
|
requestId: string;
|
||||||
|
identity: string;
|
||||||
|
source: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewSessionRequest {
|
||||||
|
sessionId: string;
|
||||||
|
source: "local" | "cloud";
|
||||||
|
identity?: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
|
const {
|
||||||
|
setCurrentSession,
|
||||||
|
clearSession
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
||||||
|
|
||||||
|
const { requireSessionApproval } = useSettingsStore();
|
||||||
|
const { handleSessionEvent } = useSessionEvents(sendFn);
|
||||||
|
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
|
||||||
|
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
|
||||||
|
|
||||||
|
const handleSessionResponse = useCallback((response: SessionResponse) => {
|
||||||
|
if (response.sessionId && response.mode) {
|
||||||
|
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
|
||||||
|
}
|
||||||
|
}, [setCurrentSession]);
|
||||||
|
|
||||||
|
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
|
||||||
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to approve primary request:", response.error);
|
||||||
|
reject(new Error(response.error.message || "Failed to approve"));
|
||||||
|
} else {
|
||||||
|
setPrimaryControlRequest(null);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [sendFn]);
|
||||||
|
|
||||||
|
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
|
||||||
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to deny primary request:", response.error);
|
||||||
|
reject(new Error(response.error.message || "Failed to deny"));
|
||||||
|
} else {
|
||||||
|
setPrimaryControlRequest(null);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [sendFn]);
|
||||||
|
|
||||||
|
const handleApproveNewSession = useCallback(async (sessionId: string) => {
|
||||||
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
sendFn("approveNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to approve new session:", response.error);
|
||||||
|
reject(new Error(response.error.message || "Failed to approve"));
|
||||||
|
} else {
|
||||||
|
setNewSessionRequest(null);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [sendFn]);
|
||||||
|
|
||||||
|
const handleDenyNewSession = useCallback(async (sessionId: string) => {
|
||||||
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
sendFn("denyNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Failed to deny new session:", response.error);
|
||||||
|
reject(new Error(response.error.message || "Failed to deny"));
|
||||||
|
} else {
|
||||||
|
setNewSessionRequest(null);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [sendFn]);
|
||||||
|
|
||||||
|
const handleRpcEvent = useCallback((method: string, params: unknown) => {
|
||||||
|
if (method === "sessionsUpdated" ||
|
||||||
|
method === "modeChanged" ||
|
||||||
|
method === "connectionModeChanged" ||
|
||||||
|
method === "otherSessionConnected") {
|
||||||
|
handleSessionEvent(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "newSessionPending" && requireSessionApproval) {
|
||||||
|
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
|
||||||
|
setNewSessionRequest(params as NewSessionRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "primaryControlRequested") {
|
||||||
|
setPrimaryControlRequest(params as PrimaryControlRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "primaryControlApproved") {
|
||||||
|
const { setRequestingPrimary } = useSessionStore.getState();
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "primaryControlDenied") {
|
||||||
|
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
|
||||||
|
setRequestingPrimary(false);
|
||||||
|
setSessionError("Your primary control request was denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "sessionAccessDenied") {
|
||||||
|
const { setSessionError } = useSessionStore.getState();
|
||||||
|
const errorParams = params as { message?: string };
|
||||||
|
setSessionError(errorParams.message || "Session access was denied by the primary session");
|
||||||
|
}
|
||||||
|
}, [handleSessionEvent, hasPermission, isLoadingPermissions, requireSessionApproval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
|
||||||
|
setNewSessionRequest(null);
|
||||||
|
}
|
||||||
|
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearSession();
|
||||||
|
};
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSessionResponse,
|
||||||
|
handleRpcEvent,
|
||||||
|
primaryControlRequest,
|
||||||
|
handleApprovePrimaryRequest,
|
||||||
|
handleDenyPrimaryRequest,
|
||||||
|
closePrimaryControlRequest: () => setPrimaryControlRequest(null),
|
||||||
|
newSessionRequest,
|
||||||
|
handleApproveNewSession,
|
||||||
|
handleDenyNewSession,
|
||||||
|
closeNewSessionRequest: () => setNewSessionRequest(null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.sett
|
||||||
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
|
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
|
||||||
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
|
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
|
||||||
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
|
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
|
||||||
|
const SettingsMultiSessionsRoute = lazy(() => import("@routes/devices.$id.settings.multi-session"));
|
||||||
|
|
||||||
export const isOnDevice = import.meta.env.MODE === "device";
|
export const isOnDevice = import.meta.env.MODE === "device";
|
||||||
export const isInCloud = !isOnDevice;
|
export const isInCloud = !isOnDevice;
|
||||||
|
|
@ -211,6 +212,10 @@ if (isOnDevice) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "sessions",
|
||||||
|
element: <SettingsMultiSessionsRoute />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -344,6 +349,10 @@ if (isOnDevice) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "sessions",
|
||||||
|
element: <SettingsMultiSessionsRoute />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
ExclamationTriangleIcon
|
||||||
|
} from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
|
|
@ -57,6 +62,32 @@ const notifications = {
|
||||||
{ duration: 2000, ...options },
|
{ duration: 2000, ...options },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
info: (message: string, options?: NotificationOptions) => {
|
||||||
|
return toast.custom(
|
||||||
|
t => (
|
||||||
|
<ToastContent
|
||||||
|
icon={<InformationCircleIcon className="w-5 h-5 text-blue-500 dark:text-blue-400" />}
|
||||||
|
message={message}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 2000, ...options },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
warning: (message: string, options?: NotificationOptions) => {
|
||||||
|
return toast.custom(
|
||||||
|
t => (
|
||||||
|
<ToastContent
|
||||||
|
icon={<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500 dark:text-yellow-400" />}
|
||||||
|
message={message}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 3000, ...options },
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function useMaxToasts(max: number) {
|
function useMaxToasts(max: number) {
|
||||||
|
|
@ -82,7 +113,12 @@ export function Notifications({
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export default Object.assign(Notifications, {
|
export const notify = {
|
||||||
success: notifications.success,
|
success: notifications.success,
|
||||||
error: notifications.error,
|
error: notifications.error,
|
||||||
});
|
info: notifications.info,
|
||||||
|
warning: notifications.warning,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export default Object.assign(Notifications, notify);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
import { PermissionsContextValue } from "@/hooks/usePermissions";
|
||||||
|
import { PermissionsContext } from "@/contexts/PermissionsContext";
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
interface PermissionsResponse {
|
||||||
|
mode: string;
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { currentMode } = useSessionStore();
|
||||||
|
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
|
||||||
|
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const previousCanControl = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const pollPermissions = useCallback((send: RpcSendFunction) => {
|
||||||
|
if (!send) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (!response.error && response.result) {
|
||||||
|
const result = response.result as PermissionsResponse;
|
||||||
|
setPermissions(result.permissions);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
pollPermissions(send);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentMode, rpcDataChannel?.readyState]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback((permission: Permission): boolean => {
|
||||||
|
return permissions[permission] === true;
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback((...perms: Permission[]): boolean => {
|
||||||
|
return perms.some(perm => hasPermission(perm));
|
||||||
|
}, [hasPermission]);
|
||||||
|
|
||||||
|
const hasAllPermissions = useCallback((...perms: Permission[]): boolean => {
|
||||||
|
return perms.every(perm => hasPermission(perm));
|
||||||
|
}, [hasPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
|
||||||
|
const hadControl = previousCanControl.current;
|
||||||
|
|
||||||
|
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
|
||||||
|
console.info("Gained control permissions, re-initializing HID");
|
||||||
|
|
||||||
|
setRpcHidProtocolVersion(null);
|
||||||
|
|
||||||
|
import("@/hooks/hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (rpcHidChannel?.readyState === "open") {
|
||||||
|
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
|
||||||
|
try {
|
||||||
|
const data = handshakeMessage.marshal();
|
||||||
|
rpcHidChannel.send(data as unknown as ArrayBuffer);
|
||||||
|
console.info("Sent HID handshake after permission change");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send HID handshake", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCanControl.current = currentCanControl;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
|
||||||
|
|
||||||
|
const isPrimary = useCallback(() => currentMode === "primary", [currentMode]);
|
||||||
|
const isObserver = useCallback(() => currentMode === "observer", [currentMode]);
|
||||||
|
const isPending = useCallback(() => currentMode === "pending", [currentMode]);
|
||||||
|
|
||||||
|
const value: PermissionsContextValue = {
|
||||||
|
permissions,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
isPrimary,
|
||||||
|
isObserver,
|
||||||
|
isPending,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PermissionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -201,6 +201,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
if ("error" in resp) return console.error(resp.error);
|
if ("error" in resp) return console.error(resp.error);
|
||||||
setDeviceId(resp.result as string);
|
setDeviceId(resp.result as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
}, [send, getCloudState, getTLSState]);
|
}, [send, getCloudState, getTLSState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -327,6 +328,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title="Remote"
|
title="Remote"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
|
|
@ -17,6 +19,7 @@ export default function SettingsHardwareRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { setDisplayRotation } = useSettingsStore();
|
const { setDisplayRotation } = useSettingsStore();
|
||||||
|
const { hasPermission, isLoading, permissions } = usePermissions();
|
||||||
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
|
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
|
||||||
|
|
||||||
const handleDisplayRotationChange = (rotation: string) => {
|
const handleDisplayRotationChange = (rotation: string) => {
|
||||||
|
|
@ -77,16 +80,19 @@ export default function SettingsHardwareRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
// Only fetch settings if user has permission
|
||||||
if ("error" in resp) {
|
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
|
||||||
return notifications.error(
|
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
if ("error" in resp) {
|
||||||
);
|
return notifications.error(
|
||||||
}
|
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||||
const result = resp.result as BacklightSettings;
|
);
|
||||||
setBacklightSettings(result);
|
}
|
||||||
});
|
const result = resp.result as BacklightSettings;
|
||||||
}, [send, setBacklightSettings]);
|
setBacklightSettings(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [send, setBacklightSettings, isLoading, permissions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
|
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
|
||||||
|
|
@ -99,6 +105,24 @@ export default function SettingsHardwareRoute() {
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
// Return early if permissions are loading
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early if no permission
|
||||||
|
if (!hasPermission(Permission.SETTINGS_READ)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-red-500">Access Denied: You do not have permission to view these settings.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
UserGroupIcon,
|
||||||
|
} from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
|
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { notify } from "@/notifications";
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import Checkbox from "@/components/Checkbox";
|
||||||
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||||
|
import { SettingsItem } from "@/components/SettingsItem";
|
||||||
|
|
||||||
|
export default function SessionsSettings() {
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const canModifySettings = hasPermission(Permission.SETTINGS_WRITE);
|
||||||
|
|
||||||
|
const {
|
||||||
|
requireSessionNickname,
|
||||||
|
setRequireSessionNickname,
|
||||||
|
requireSessionApproval,
|
||||||
|
setRequireSessionApproval,
|
||||||
|
maxRejectionAttempts,
|
||||||
|
setMaxRejectionAttempts
|
||||||
|
} = useSettingsStore();
|
||||||
|
|
||||||
|
const [reconnectGrace, setReconnectGrace] = useState(10);
|
||||||
|
const [primaryTimeout, setPrimaryTimeout] = useState(300);
|
||||||
|
const [privateKeystrokes, setPrivateKeystrokes] = useState(false);
|
||||||
|
const [maxSessions, setMaxSessions] = useState(10);
|
||||||
|
const [observerTimeout, setObserverTimeout] = useState(120);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getSessionSettings", {}, (response: JsonRpcResponse) => {
|
||||||
|
if ("error" in response) {
|
||||||
|
console.error("Failed to get session settings:", response.error);
|
||||||
|
} else {
|
||||||
|
const settings = response.result as {
|
||||||
|
requireApproval: boolean;
|
||||||
|
requireNickname: boolean;
|
||||||
|
reconnectGrace?: number;
|
||||||
|
primaryTimeout?: number;
|
||||||
|
privateKeystrokes?: boolean;
|
||||||
|
maxRejectionAttempts?: number;
|
||||||
|
maxSessions?: number;
|
||||||
|
observerTimeout?: number;
|
||||||
|
};
|
||||||
|
setRequireSessionApproval(settings.requireApproval);
|
||||||
|
setRequireSessionNickname(settings.requireNickname);
|
||||||
|
if (settings.reconnectGrace !== undefined) {
|
||||||
|
setReconnectGrace(settings.reconnectGrace);
|
||||||
|
}
|
||||||
|
if (settings.primaryTimeout !== undefined) {
|
||||||
|
setPrimaryTimeout(settings.primaryTimeout);
|
||||||
|
}
|
||||||
|
if (settings.privateKeystrokes !== undefined) {
|
||||||
|
setPrivateKeystrokes(settings.privateKeystrokes);
|
||||||
|
}
|
||||||
|
if (settings.maxRejectionAttempts !== undefined) {
|
||||||
|
setMaxRejectionAttempts(settings.maxRejectionAttempts);
|
||||||
|
}
|
||||||
|
if (settings.maxSessions !== undefined) {
|
||||||
|
setMaxSessions(settings.maxSessions);
|
||||||
|
}
|
||||||
|
if (settings.observerTimeout !== undefined) {
|
||||||
|
setObserverTimeout(settings.observerTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]);
|
||||||
|
|
||||||
|
const updateSessionSettings = (updates: Partial<{
|
||||||
|
requireApproval: boolean;
|
||||||
|
requireNickname: boolean;
|
||||||
|
reconnectGrace: number;
|
||||||
|
primaryTimeout: number;
|
||||||
|
privateKeystrokes: boolean;
|
||||||
|
maxRejectionAttempts: number;
|
||||||
|
maxSessions: number;
|
||||||
|
observerTimeout: number;
|
||||||
|
}>) => {
|
||||||
|
if (!canModifySettings) {
|
||||||
|
notify.error("Only the primary session can change this setting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send("setSessionSettings", {
|
||||||
|
settings: {
|
||||||
|
requireApproval: requireSessionApproval,
|
||||||
|
requireNickname: requireSessionNickname,
|
||||||
|
reconnectGrace: reconnectGrace,
|
||||||
|
primaryTimeout: primaryTimeout,
|
||||||
|
privateKeystrokes: privateKeystrokes,
|
||||||
|
maxRejectionAttempts: maxRejectionAttempts,
|
||||||
|
maxSessions: maxSessions,
|
||||||
|
observerTimeout: observerTimeout,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
}, (response: JsonRpcResponse) => {
|
||||||
|
if ("error" in response) {
|
||||||
|
console.error("Failed to update session settings:", response.error);
|
||||||
|
notify.error("Failed to update session settings");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title="Multi-Session Access"
|
||||||
|
description="Configure multi-session access and control settings"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!canModifySettings && (
|
||||||
|
<Card className="border-amber-500/20 bg-amber-50 dark:bg-amber-900/10">
|
||||||
|
<div className="p-4 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<strong>Note:</strong> Only the primary session can modify these settings.
|
||||||
|
Request primary control to change settings.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<UserGroupIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
Access Control
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Require Session Approval"
|
||||||
|
description="New sessions must be approved by the primary session before gaining access"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={requireSessionApproval}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = e.target.checked;
|
||||||
|
setRequireSessionApproval(newValue);
|
||||||
|
updateSessionSettings({ requireApproval: newValue });
|
||||||
|
notify.success(
|
||||||
|
newValue
|
||||||
|
? "New sessions will require approval"
|
||||||
|
: "New sessions will be automatically approved"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Require Session Nicknames"
|
||||||
|
description="All sessions must provide a nickname for identification"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={requireSessionNickname}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = e.target.checked;
|
||||||
|
setRequireSessionNickname(newValue);
|
||||||
|
updateSessionSettings({ requireNickname: newValue });
|
||||||
|
notify.success(
|
||||||
|
newValue
|
||||||
|
? "Session nicknames are now required"
|
||||||
|
: "Session nicknames are now optional"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Maximum Rejection Attempts"
|
||||||
|
description="Number of times a denied session can re-request approval before the modal is hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={maxRejectionAttempts}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 3;
|
||||||
|
if (newValue < 1 || newValue > 10) {
|
||||||
|
notify.error("Maximum attempts must be between 1 and 10");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMaxRejectionAttempts(newValue);
|
||||||
|
updateSessionSettings({ maxRejectionAttempts: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Denied sessions can now retry up to ${newValue} time${newValue === 1 ? '' : 's'}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">attempts</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Reconnect Grace Period"
|
||||||
|
description="Time to wait for a session to reconnect before reassigning control"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="60"
|
||||||
|
value={reconnectGrace}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 10;
|
||||||
|
if (newValue < 5 || newValue > 60) {
|
||||||
|
notify.error("Grace period must be between 5 and 60 seconds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReconnectGrace(newValue);
|
||||||
|
updateSessionSettings({ reconnectGrace: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Session will have ${newValue} seconds to reconnect`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Primary Session Timeout"
|
||||||
|
description="Time of inactivity before the primary session loses control (0 = disabled)"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="3600"
|
||||||
|
step="60"
|
||||||
|
value={primaryTimeout}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 0;
|
||||||
|
if (newValue < 0 || newValue > 3600) {
|
||||||
|
notify.error("Timeout must be between 0 and 3600 seconds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrimaryTimeout(newValue);
|
||||||
|
updateSessionSettings({ primaryTimeout: newValue });
|
||||||
|
notify.success(
|
||||||
|
newValue === 0
|
||||||
|
? "Primary session timeout disabled"
|
||||||
|
: `Primary session will timeout after ${Math.round(newValue / 60)} minutes of inactivity`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-24 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Maximum Concurrent Sessions"
|
||||||
|
description="Maximum number of sessions that can connect simultaneously"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={maxSessions}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 10;
|
||||||
|
if (newValue < 1 || newValue > 20) {
|
||||||
|
notify.error("Max sessions must be between 1 and 20");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMaxSessions(newValue);
|
||||||
|
updateSessionSettings({ maxSessions: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Maximum concurrent sessions set to ${newValue}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">sessions</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Observer Cleanup Timeout"
|
||||||
|
description="Time to wait before cleaning up inactive observer sessions with closed connections"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
max="600"
|
||||||
|
step="30"
|
||||||
|
value={observerTimeout}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = parseInt(e.target.value) || 120;
|
||||||
|
if (newValue < 30 || newValue > 600) {
|
||||||
|
notify.error("Timeout must be between 30 and 600 seconds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setObserverTimeout(newValue);
|
||||||
|
updateSessionSettings({ observerTimeout: newValue });
|
||||||
|
notify.success(
|
||||||
|
`Observer cleanup timeout set to ${Math.round(newValue / 60)} minute${Math.round(newValue / 60) === 1 ? '' : 's'}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title="Private Keystrokes"
|
||||||
|
description="When enabled, only the primary session can see keystroke events"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={privateKeystrokes}
|
||||||
|
disabled={!canModifySettings}
|
||||||
|
onChange={e => {
|
||||||
|
const newValue = e.target.checked;
|
||||||
|
setPrivateKeystrokes(newValue);
|
||||||
|
updateSessionSettings({ privateKeystrokes: newValue });
|
||||||
|
notify.success(
|
||||||
|
newValue
|
||||||
|
? "Keystrokes are now private to primary session"
|
||||||
|
: "Keystrokes are visible to all authorized sessions"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
How Multi-Session Access Works
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Primary:</span>
|
||||||
|
<span>Full control over the KVM device including keyboard, mouse, and settings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Observer:</span>
|
||||||
|
<span>View-only access to monitor activity without control capabilities</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Pending:</span>
|
||||||
|
<span>Awaiting approval from the primary session (when approval is required)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Use the Sessions panel in the top navigation bar to view and manage active sessions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router";
|
import { NavLink, Outlet, useLocation , useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
LuSettings,
|
LuSettings,
|
||||||
LuMouse,
|
LuMouse,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
LuPalette,
|
LuPalette,
|
||||||
LuCommand,
|
LuCommand,
|
||||||
LuNetwork,
|
LuNetwork,
|
||||||
|
LuUsers,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { useResizeObserver } from "usehooks-ts";
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
|
|
@ -20,11 +21,24 @@ import Card from "@components/Card";
|
||||||
import { LinkButton } from "@components/Button";
|
import { LinkButton } from "@components/Button";
|
||||||
import { FeatureFlag } from "@components/FeatureFlag";
|
import { FeatureFlag } from "@components/FeatureFlag";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||||
export default function SettingsRoute() {
|
export default function SettingsRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setDisableVideoFocusTrap } = useUiStore();
|
const { setDisableVideoFocusTrap } = useUiStore();
|
||||||
|
const { currentMode } = useSessionStore();
|
||||||
|
const { hasPermission, isLoading, permissions } = usePermissions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}
|
||||||
|
}, [permissions, isLoading, currentMode, navigate]);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||||
|
|
@ -69,6 +83,21 @@ export default function SettingsRoute() {
|
||||||
};
|
};
|
||||||
}, [setDisableVideoFocusTrap]);
|
}, [setDisableVideoFocusTrap]);
|
||||||
|
|
||||||
|
// Check permissions first - return early to prevent any content flash
|
||||||
|
// Show loading state while permissions are being checked
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-500">Checking permissions...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render settings content if user doesn't have permission
|
||||||
|
if (!hasPermission(Permission.SETTINGS_ACCESS)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
@ -223,6 +252,17 @@ export default function SettingsRoute() {
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<NavLink
|
||||||
|
to="sessions"
|
||||||
|
className={({ isActive }) => (isActive ? "active" : "")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
|
||||||
|
<LuUsers className="h-4 w-4 shrink-0" />
|
||||||
|
<h1>Multi-Session Access</h1>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="advanced"
|
to="advanced"
|
||||||
|
|
|
||||||
|
|
@ -30,18 +30,30 @@ import {
|
||||||
useNetworkStateStore,
|
useNetworkStateStore,
|
||||||
User,
|
User,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useUpdateStore,
|
useUpdateStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
VideoState,
|
VideoState,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import WebRTCVideo from "@components/WebRTCVideo";
|
import WebRTCVideo from "@components/WebRTCVideo";
|
||||||
|
import UnifiedSessionRequestDialog from "@components/UnifiedSessionRequestDialog";
|
||||||
|
import NicknameModal from "@components/NicknameModal";
|
||||||
|
import AccessDeniedOverlay from "@components/AccessDeniedOverlay";
|
||||||
|
import PendingApprovalOverlay from "@components/PendingApprovalOverlay";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
const ConnectionStatsSidebar = lazy(() => import("@/components/sidebar/connectionStats"));
|
||||||
const Terminal = lazy(() => import('@components/Terminal'));
|
const Terminal = lazy(() => import("@components/Terminal"));
|
||||||
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
|
const UpdateInProgressStatusCard = lazy(
|
||||||
|
() => import("@/components/UpdateInProgressStatusCard"),
|
||||||
|
);
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
|
import {
|
||||||
|
JsonRpcRequest,
|
||||||
|
JsonRpcResponse,
|
||||||
|
RpcMethodNotFound,
|
||||||
|
useJsonRpc,
|
||||||
|
} from "@/hooks/useJsonRpc";
|
||||||
import {
|
import {
|
||||||
ConnectionFailedOverlay,
|
ConnectionFailedOverlay,
|
||||||
LoadingConnectionOverlay,
|
LoadingConnectionOverlay,
|
||||||
|
|
@ -50,8 +62,14 @@ import {
|
||||||
} from "@/components/VideoOverlay";
|
} from "@/components/VideoOverlay";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
||||||
|
import { PermissionsProvider } from "@/providers/PermissionsProvider";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import { DeviceStatus } from "@routes/welcome-local";
|
import { DeviceStatus } from "@routes/welcome-local";
|
||||||
import { useVersion } from "@/hooks/useVersion";
|
import { useVersion } from "@/hooks/useVersion";
|
||||||
|
import { useSessionManagement } from "@/hooks/useSessionManagement";
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
|
|
||||||
interface LocalLoaderResp {
|
interface LocalLoaderResp {
|
||||||
authMode: "password" | "noPassword" | null;
|
authMode: "password" | "noPassword" | null;
|
||||||
|
|
@ -124,15 +142,25 @@ export default function KvmIdRoute() {
|
||||||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||||
|
|
||||||
const params = useParams() as { id: string };
|
const params = useParams() as { id: string };
|
||||||
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
|
const {
|
||||||
|
sidebarView,
|
||||||
|
setSidebarView,
|
||||||
|
disableVideoFocusTrap,
|
||||||
|
setDisableVideoFocusTrap,
|
||||||
|
rebootState,
|
||||||
|
setRebootState,
|
||||||
|
} = useUiStore();
|
||||||
const [queryParams, setQueryParams] = useSearchParams();
|
const [queryParams, setQueryParams] = useSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
peerConnection, setPeerConnection,
|
peerConnection,
|
||||||
peerConnectionState, setPeerConnectionState,
|
setPeerConnection,
|
||||||
|
peerConnectionState,
|
||||||
|
setPeerConnectionState,
|
||||||
setMediaStream,
|
setMediaStream,
|
||||||
setRpcDataChannel,
|
setRpcDataChannel,
|
||||||
isTurnServerInUse, setTurnServerInUse,
|
isTurnServerInUse,
|
||||||
|
setTurnServerInUse,
|
||||||
rpcDataChannel,
|
rpcDataChannel,
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
setRpcHidChannel,
|
setRpcHidChannel,
|
||||||
|
|
@ -143,15 +171,22 @@ export default function KvmIdRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLegacySignalingEnabled = useRef(false);
|
const isLegacySignalingEnabled = useRef(false);
|
||||||
const [connectionFailed, setConnectionFailed] = useState(false);
|
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||||
|
const [showNicknameModal, setShowNicknameModal] = useState(false);
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||||
|
const { currentSessionId, currentMode, setCurrentSession } = useSessionStore();
|
||||||
|
const { nickname, setNickname } = useSharedSessionStore();
|
||||||
|
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
|
||||||
|
const [globalSessionSettings, setGlobalSessionSettings] = useState<{
|
||||||
|
requireApproval: boolean;
|
||||||
|
requireNickname: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||||
const cleanupAndStopReconnecting = useCallback(
|
const cleanupAndStopReconnecting = useCallback(
|
||||||
function cleanupAndStopReconnecting() {
|
function cleanupAndStopReconnecting() {
|
||||||
console.log("Closing peer connection");
|
|
||||||
|
|
||||||
setConnectionFailed(true);
|
setConnectionFailed(true);
|
||||||
if (peerConnection) {
|
if (peerConnection) {
|
||||||
setPeerConnectionState(peerConnection.connectionState);
|
setPeerConnectionState(peerConnection.connectionState);
|
||||||
|
|
@ -188,7 +223,6 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||||
console.log("[setRemoteSessionDescription] Remote description set successfully");
|
|
||||||
setLoadingMessage("Establishing secure connection...");
|
setLoadingMessage("Establishing secure connection...");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -206,7 +240,6 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||||
if (pc.sctp?.state === "connected") {
|
if (pc.sctp?.state === "connected") {
|
||||||
console.log("[setRemoteSessionDescription] Remote description set");
|
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
setLoadingMessage("Connection established");
|
setLoadingMessage("Connection established");
|
||||||
} else if (attempts >= 10) {
|
} else if (attempts >= 10) {
|
||||||
|
|
@ -219,11 +252,6 @@ export default function KvmIdRoute() {
|
||||||
);
|
);
|
||||||
cleanupAndStopReconnecting();
|
cleanupAndStopReconnecting();
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
} else {
|
|
||||||
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
|
|
||||||
connectionState: pc.connectionState,
|
|
||||||
iceConnectionState: pc.iceConnectionState,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
|
|
@ -246,7 +274,6 @@ export default function KvmIdRoute() {
|
||||||
reconnectAttempts: 2000,
|
reconnectAttempts: 2000,
|
||||||
reconnectInterval: 1000,
|
reconnectInterval: 1000,
|
||||||
onReconnectStop: () => {
|
onReconnectStop: () => {
|
||||||
console.debug("Reconnect stopped");
|
|
||||||
cleanupAndStopReconnecting();
|
cleanupAndStopReconnecting();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -255,9 +282,8 @@ export default function KvmIdRoute() {
|
||||||
return !isLegacySignalingEnabled.current;
|
return !isLegacySignalingEnabled.current;
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(event) {
|
onClose(_event) {
|
||||||
console.debug("[Websocket] onClose", event);
|
// Handled by onReconnectStop instead
|
||||||
// We don't want to close everything down, we wait for the reconnect to stop instead
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onError(event) {
|
onError(event) {
|
||||||
|
|
@ -296,27 +322,51 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const parsedMessage = JSON.parse(message.data);
|
const parsedMessage = JSON.parse(message.data);
|
||||||
if (parsedMessage.type === "device-metadata") {
|
if (parsedMessage.type === "device-metadata") {
|
||||||
const { deviceVersion } = parsedMessage.data;
|
const { deviceVersion, sessionSettings } = parsedMessage.data;
|
||||||
console.debug("[Websocket] Received device-metadata message");
|
|
||||||
console.debug("[Websocket] Device version", deviceVersion);
|
// Store session settings if provided
|
||||||
|
if (sessionSettings) {
|
||||||
|
setGlobalSessionSettings({
|
||||||
|
requireNickname: sessionSettings.requireNickname || false,
|
||||||
|
requireApproval: sessionSettings.requireApproval || false,
|
||||||
|
});
|
||||||
|
// Also update the settings store for approval handling
|
||||||
|
setRequireSessionApproval(sessionSettings.requireApproval || false);
|
||||||
|
setRequireSessionNickname(sessionSettings.requireNickname || false);
|
||||||
|
}
|
||||||
|
|
||||||
// If the device version is not set, we can assume the device is using the legacy signaling
|
// If the device version is not set, we can assume the device is using the legacy signaling
|
||||||
if (!deviceVersion) {
|
if (!deviceVersion) {
|
||||||
console.log("[Websocket] Device is using legacy signaling");
|
|
||||||
|
|
||||||
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
||||||
// which does everything over HTTP(at least from the perspective of the client)
|
// which does everything over HTTP(at least from the perspective of the client)
|
||||||
isLegacySignalingEnabled.current = true;
|
isLegacySignalingEnabled.current = true;
|
||||||
getWebSocket()?.close();
|
getWebSocket()?.close();
|
||||||
} else {
|
} else {
|
||||||
console.log("[Websocket] Device is using new signaling");
|
|
||||||
isLegacySignalingEnabled.current = false;
|
isLegacySignalingEnabled.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always setup peer connection first to establish RPC channel for nickname generation
|
||||||
setupPeerConnection();
|
setupPeerConnection();
|
||||||
|
|
||||||
|
// Check if nickname is required and not set - modal will be shown after RPC channel is ready
|
||||||
|
const requiresNickname = sessionSettings?.requireNickname || false;
|
||||||
|
|
||||||
|
if (requiresNickname && !nickname) {
|
||||||
|
// Store that we need to show the nickname modal once RPC is ready
|
||||||
|
// The useEffect in NicknameModal will handle waiting for RPC channel readiness
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!peerConnection) return;
|
if (!peerConnection) {
|
||||||
|
console.warn(
|
||||||
|
"[Websocket] Ignoring message because peerConnection is not ready:",
|
||||||
|
parsedMessage.type,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (parsedMessage.type === "answer") {
|
if (parsedMessage.type === "answer") {
|
||||||
console.debug("[Websocket] Received answer");
|
|
||||||
const readyForOffer =
|
const readyForOffer =
|
||||||
// If we're making an offer, we don't want to accept an answer
|
// If we're making an offer, we don't want to accept an answer
|
||||||
!makingOffer &&
|
!makingOffer &&
|
||||||
|
|
@ -330,14 +380,46 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// Set so we don't accept an answer while we're setting the remote description
|
// Set so we don't accept an answer while we're setting the remote description
|
||||||
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
||||||
console.debug(
|
|
||||||
"[Websocket] Setting remote answer pending",
|
|
||||||
isSettingRemoteAnswerPending.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sd = atob(parsedMessage.data);
|
const sd = atob(parsedMessage.data);
|
||||||
const remoteSessionDescription = JSON.parse(sd);
|
const remoteSessionDescription = JSON.parse(sd);
|
||||||
|
|
||||||
|
if (parsedMessage.sessionId && parsedMessage.mode) {
|
||||||
|
handleSessionResponse({
|
||||||
|
sessionId: parsedMessage.sessionId,
|
||||||
|
mode: parsedMessage.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store sessionId via zustand (persists to sessionStorage for per-tab isolation)
|
||||||
|
setCurrentSession(parsedMessage.sessionId, parsedMessage.mode);
|
||||||
|
if (
|
||||||
|
parsedMessage.requireNickname !== undefined &&
|
||||||
|
parsedMessage.requireApproval !== undefined
|
||||||
|
) {
|
||||||
|
setGlobalSessionSettings({
|
||||||
|
requireNickname: parsedMessage.requireNickname,
|
||||||
|
requireApproval: parsedMessage.requireApproval,
|
||||||
|
});
|
||||||
|
// Also update the settings store for approval handling
|
||||||
|
setRequireSessionApproval(parsedMessage.requireApproval);
|
||||||
|
setRequireSessionNickname(parsedMessage.requireNickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show nickname modal if:
|
||||||
|
// 1. Nickname is required by backend settings
|
||||||
|
// 2. We don't already have a nickname
|
||||||
|
// This happens even for pending sessions so the nickname is included in approval
|
||||||
|
const hasNickname =
|
||||||
|
parsedMessage.nickname && parsedMessage.nickname.length > 0;
|
||||||
|
const requiresNickname =
|
||||||
|
parsedMessage.requireNickname || globalSessionSettings?.requireNickname;
|
||||||
|
|
||||||
|
if (requiresNickname && !hasNickname) {
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRemoteSessionDescription(
|
setRemoteSessionDescription(
|
||||||
peerConnection,
|
peerConnection,
|
||||||
new RTCSessionDescription(remoteSessionDescription),
|
new RTCSessionDescription(remoteSessionDescription),
|
||||||
|
|
@ -346,21 +428,63 @@ export default function KvmIdRoute() {
|
||||||
// Reset the remote answer pending flag
|
// Reset the remote answer pending flag
|
||||||
isSettingRemoteAnswerPending.current = false;
|
isSettingRemoteAnswerPending.current = false;
|
||||||
} else if (parsedMessage.type === "new-ice-candidate") {
|
} else if (parsedMessage.type === "new-ice-candidate") {
|
||||||
console.debug("[Websocket] Received new-ice-candidate");
|
|
||||||
const candidate = parsedMessage.data;
|
const candidate = parsedMessage.data;
|
||||||
peerConnection.addIceCandidate(candidate);
|
// Always try to add the ICE candidate - the browser will queue it internally if needed
|
||||||
|
peerConnection.addIceCandidate(candidate).catch(error => {
|
||||||
|
console.warn("[Websocket] Failed to add ICE candidate:", error);
|
||||||
|
});
|
||||||
|
} else if (parsedMessage.type === "connectionModeChanged") {
|
||||||
|
// Handle mode changes via WebSocket (fallback when RPC channel stale)
|
||||||
|
const { newMode, action } = parsedMessage.data;
|
||||||
|
|
||||||
|
if (action === "reconnect_required" && newMode) {
|
||||||
|
// Update session state immediately
|
||||||
|
if (currentSessionId) {
|
||||||
|
setCurrentSession(currentSessionId, newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger RPC event handler
|
||||||
|
handleRpcEvent("connectionModeChanged", parsedMessage.data);
|
||||||
|
|
||||||
|
// Only reconnect if the peer connection is actually stale
|
||||||
|
// If already connected, the mode change via RPC is sufficient
|
||||||
|
const isConnectionHealthy =
|
||||||
|
peerConnection?.connectionState === "connected" &&
|
||||||
|
peerConnection?.iceConnectionState === "connected";
|
||||||
|
|
||||||
|
if (!isConnectionHealthy) {
|
||||||
|
console.log(
|
||||||
|
`[Websocket] Mode changed to ${newMode}, connection unhealthy, reconnecting...`,
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
peerConnection?.close();
|
||||||
|
setupPeerConnection();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[Websocket] Mode changed to ${newMode}, connection healthy, skipping reconnect`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendWebRTCSignal = useCallback(
|
const sendWebRTCSignal = useCallback(
|
||||||
(type: string, data: unknown) => {
|
(type: string, data: unknown) => {
|
||||||
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
||||||
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
||||||
sendMessage(JSON.stringify({ type, data }), false);
|
const message = JSON.stringify({ type, data });
|
||||||
|
const ws = getWebSocket();
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
sendMessage(message, false);
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSocket] WebSocket not open, queuing message:`, message);
|
||||||
|
sendMessage(message, true); // Queue the message
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[sendMessage],
|
[sendMessage, getWebSocket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const legacyHTTPSignaling = useCallback(
|
const legacyHTTPSignaling = useCallback(
|
||||||
|
|
@ -371,12 +495,12 @@ export default function KvmIdRoute() {
|
||||||
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
||||||
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||||
|
|
||||||
console.log("Trying to get remote session description");
|
|
||||||
setLoadingMessage(
|
setLoadingMessage(
|
||||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||||
);
|
);
|
||||||
const res = await api.POST(sessionUrl, {
|
const res = await api.POST(sessionUrl, {
|
||||||
sd,
|
sd,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
// When on device, we don't need to specify the device id, as it's already known
|
// When on device, we don't need to specify the device id, as it's already known
|
||||||
...(isOnDevice ? {} : { id: params.id }),
|
...(isOnDevice ? {} : { id: params.id }),
|
||||||
});
|
});
|
||||||
|
|
@ -389,7 +513,6 @@ export default function KvmIdRoute() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Successfully got Remote Session Description. Setting.");
|
|
||||||
setLoadingMessage("Setting remote session description...");
|
setLoadingMessage("Setting remote session description...");
|
||||||
|
|
||||||
const decodedSd = atob(json.sd);
|
const decodedSd = atob(json.sd);
|
||||||
|
|
@ -400,13 +523,11 @@ export default function KvmIdRoute() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const setupPeerConnection = useCallback(async () => {
|
const setupPeerConnection = useCallback(async () => {
|
||||||
console.debug("[setupPeerConnection] Setting up peer connection");
|
|
||||||
setConnectionFailed(false);
|
setConnectionFailed(false);
|
||||||
setLoadingMessage("Connecting to device...");
|
setLoadingMessage("Connecting to device...");
|
||||||
|
|
||||||
let pc: RTCPeerConnection;
|
let pc: RTCPeerConnection;
|
||||||
try {
|
try {
|
||||||
console.debug("[setupPeerConnection] Creating peer connection");
|
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
|
|
@ -416,7 +537,6 @@ export default function KvmIdRoute() {
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
console.debug("[setupPeerConnection] Peer connection created", pc);
|
|
||||||
setLoadingMessage("Setting up connection to device...");
|
setLoadingMessage("Setting up connection to device...");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
||||||
|
|
@ -428,13 +548,11 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// Set up event listeners and data channels
|
// Set up event listeners and data channels
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onnegotiationneeded = async () => {
|
pc.onnegotiationneeded = async () => {
|
||||||
try {
|
try {
|
||||||
console.debug("[setupPeerConnection] Creating offer");
|
|
||||||
makingOffer.current = true;
|
makingOffer.current = true;
|
||||||
|
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
|
|
@ -442,9 +560,19 @@ export default function KvmIdRoute() {
|
||||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||||
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||||
if (isNewSignalingEnabled) {
|
if (isNewSignalingEnabled) {
|
||||||
sendWebRTCSignal("offer", { sd: sd });
|
// Get nickname and sessionId from zustand stores
|
||||||
} else {
|
// sessionId is per-tab (sessionStorage), nickname is shared (localStorage)
|
||||||
console.log("Legacy signaling. Waiting for ICE Gathering to complete...");
|
const { currentSessionId: storeSessionId } = useSessionStore.getState();
|
||||||
|
const { nickname: storeNickname } = useSharedSessionStore.getState();
|
||||||
|
|
||||||
|
sendWebRTCSignal("offer", {
|
||||||
|
sd: sd,
|
||||||
|
sessionId: storeSessionId || undefined,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
sessionSettings: {
|
||||||
|
nickname: storeNickname || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -458,15 +586,18 @@ export default function KvmIdRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicecandidate = ({ candidate }) => {
|
pc.onicecandidate = ({ candidate }) => {
|
||||||
if (!candidate) return;
|
if (!candidate) {
|
||||||
if (candidate.candidate === "") return;
|
return;
|
||||||
|
}
|
||||||
|
if (candidate.candidate === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendWebRTCSignal("new-ice-candidate", candidate);
|
sendWebRTCSignal("new-ice-candidate", candidate);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicegatheringstatechange = event => {
|
pc.onicegatheringstatechange = event => {
|
||||||
const pc = event.currentTarget as RTCPeerConnection;
|
const pc = event.currentTarget as RTCPeerConnection;
|
||||||
if (pc.iceGatheringState === "complete") {
|
if (pc.iceGatheringState === "complete") {
|
||||||
console.debug("ICE Gathering completed");
|
|
||||||
setLoadingMessage("ICE Gathering completed");
|
setLoadingMessage("ICE Gathering completed");
|
||||||
|
|
||||||
if (isLegacySignalingEnabled.current) {
|
if (isLegacySignalingEnabled.current) {
|
||||||
|
|
@ -474,7 +605,6 @@ export default function KvmIdRoute() {
|
||||||
legacyHTTPSignaling(pc);
|
legacyHTTPSignaling(pc);
|
||||||
}
|
}
|
||||||
} else if (pc.iceGatheringState === "gathering") {
|
} else if (pc.iceGatheringState === "gathering") {
|
||||||
console.debug("ICE Gathering Started");
|
|
||||||
setLoadingMessage("Gathering ICE candidates...");
|
setLoadingMessage("Gathering ICE candidates...");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -505,10 +635,13 @@ export default function KvmIdRoute() {
|
||||||
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
|
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", {
|
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel(
|
||||||
ordered: false,
|
"hidrpc-unreliable-nonordered",
|
||||||
maxRetransmits: 0,
|
{
|
||||||
});
|
ordered: false,
|
||||||
|
maxRetransmits: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
|
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
|
||||||
rpcHidUnreliableNonOrderedChannel.onopen = () => {
|
rpcHidUnreliableNonOrderedChannel.onopen = () => {
|
||||||
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
|
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
|
||||||
|
|
@ -599,19 +732,24 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire and forget
|
// Fire and forget
|
||||||
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
api
|
||||||
bytesReceived: bytesReceivedDelta,
|
.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
||||||
bytesSent: bytesSentDelta,
|
bytesReceived: bytesReceivedDelta,
|
||||||
}).catch(() => {
|
bytesSent: bytesSentDelta,
|
||||||
// we don't care about errors here, but we don't want unhandled promise rejections
|
})
|
||||||
});
|
.catch(() => {
|
||||||
|
// we don't care about errors here, but we don't want unhandled promise rejections
|
||||||
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
const { setNetworkState } = useNetworkStateStore();
|
const { setNetworkState } = useNetworkStateStore();
|
||||||
const { setHdmiState } = useVideoStore();
|
const { setHdmiState } = useVideoStore();
|
||||||
const {
|
const {
|
||||||
keyboardLedState, setKeyboardLedState,
|
keyboardLedState,
|
||||||
keysDownState, setKeysDownState, setUsbState,
|
setKeyboardLedState,
|
||||||
|
keysDownState,
|
||||||
|
setKeysDownState,
|
||||||
|
setUsbState,
|
||||||
} = useHidStore();
|
} = useHidStore();
|
||||||
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
|
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
|
||||||
|
|
||||||
|
|
@ -619,42 +757,56 @@ export default function KvmIdRoute() {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
||||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||||
if (resp.method === "otherSessionConnected") {
|
// Handle session-related events
|
||||||
navigateTo("/other-session");
|
if (
|
||||||
|
resp.method === "sessionsUpdated" ||
|
||||||
|
resp.method === "modeChanged" ||
|
||||||
|
resp.method === "connectionModeChanged" ||
|
||||||
|
resp.method === "otherSessionConnected" ||
|
||||||
|
resp.method === "primaryControlRequested" ||
|
||||||
|
resp.method === "primaryControlApproved" ||
|
||||||
|
resp.method === "primaryControlDenied" ||
|
||||||
|
resp.method === "newSessionPending" ||
|
||||||
|
resp.method === "sessionAccessDenied"
|
||||||
|
) {
|
||||||
|
handleRpcEvent(resp.method, resp.params);
|
||||||
|
|
||||||
|
// Show access denied overlay if our session was denied
|
||||||
|
if (resp.method === "sessionAccessDenied") {
|
||||||
|
setAccessDenied(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.method === "otherSessionConnected") {
|
||||||
|
navigateTo("/other-session");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "usbState") {
|
if (resp.method === "usbState") {
|
||||||
const usbState = resp.params as unknown as USBStates;
|
const usbState = resp.params as unknown as USBStates;
|
||||||
console.debug("Setting USB state", usbState);
|
|
||||||
setUsbState(usbState);
|
setUsbState(usbState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "videoInputState") {
|
if (resp.method === "videoInputState") {
|
||||||
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
|
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
|
||||||
console.debug("Setting HDMI state", hdmiState);
|
|
||||||
setHdmiState(hdmiState);
|
setHdmiState(hdmiState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "networkState") {
|
if (resp.method === "networkState") {
|
||||||
console.debug("Setting network state", resp.params);
|
|
||||||
setNetworkState(resp.params as NetworkState);
|
setNetworkState(resp.params as NetworkState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "keyboardLedState") {
|
if (resp.method === "keyboardLedState") {
|
||||||
const ledState = resp.params as KeyboardLedState;
|
const ledState = resp.params as KeyboardLedState;
|
||||||
console.debug("Setting keyboard led state", ledState);
|
|
||||||
setKeyboardLedState(ledState);
|
setKeyboardLedState(ledState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "keysDownState") {
|
if (resp.method === "keysDownState") {
|
||||||
const downState = resp.params as KeysDownState;
|
const downState = resp.params as KeysDownState;
|
||||||
console.debug("Setting key down state:", downState);
|
|
||||||
setKeysDownState(downState);
|
setKeysDownState(downState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "otaState") {
|
if (resp.method === "otaState") {
|
||||||
const otaState = resp.params as OtaState;
|
const otaState = resp.params as OtaState;
|
||||||
console.debug("Setting OTA state", otaState);
|
|
||||||
setOtaState(otaState);
|
setOtaState(otaState);
|
||||||
|
|
||||||
if (otaState.updating === true) {
|
if (otaState.updating === true) {
|
||||||
|
|
@ -687,24 +839,44 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSessionResponse,
|
||||||
|
handleRpcEvent,
|
||||||
|
primaryControlRequest,
|
||||||
|
handleApprovePrimaryRequest,
|
||||||
|
handleDenyPrimaryRequest,
|
||||||
|
closePrimaryControlRequest,
|
||||||
|
newSessionRequest,
|
||||||
|
handleApproveNewSession,
|
||||||
|
handleDenyNewSession,
|
||||||
|
closeNewSessionRequest,
|
||||||
|
} = useSessionManagement(send);
|
||||||
|
|
||||||
|
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
console.log("Requesting video state");
|
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
|
||||||
|
|
||||||
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
||||||
console.debug("Setting HDMI state", hdmiState);
|
|
||||||
setHdmiState(hdmiState);
|
setHdmiState(hdmiState);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
}, [
|
||||||
|
rpcDataChannel?.readyState,
|
||||||
|
hasPermission,
|
||||||
|
isLoadingPermissions,
|
||||||
|
send,
|
||||||
|
setHdmiState,
|
||||||
|
]);
|
||||||
|
|
||||||
const [needLedState, setNeedLedState] = useState(true);
|
const [needLedState, setNeedLedState] = useState(true);
|
||||||
|
|
||||||
// request keyboard led state from the device
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needLedState) return;
|
if (!needLedState) return;
|
||||||
console.log("Requesting keyboard led state");
|
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||||
|
|
||||||
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
|
|
@ -712,39 +884,54 @@ export default function KvmIdRoute() {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
const ledState = resp.result as KeyboardLedState;
|
const ledState = resp.result as KeyboardLedState;
|
||||||
console.debug("Keyboard led state: ", ledState);
|
|
||||||
setKeyboardLedState(ledState);
|
setKeyboardLedState(ledState);
|
||||||
}
|
}
|
||||||
setNeedLedState(false);
|
setNeedLedState(false);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
|
}, [
|
||||||
|
rpcDataChannel?.readyState,
|
||||||
|
send,
|
||||||
|
setKeyboardLedState,
|
||||||
|
keyboardLedState,
|
||||||
|
needLedState,
|
||||||
|
hasPermission,
|
||||||
|
isLoadingPermissions,
|
||||||
|
]);
|
||||||
|
|
||||||
const [needKeyDownState, setNeedKeyDownState] = useState(true);
|
const [needKeyDownState, setNeedKeyDownState] = useState(true);
|
||||||
|
|
||||||
// request keyboard key down state from the device
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needKeyDownState) return;
|
if (!needKeyDownState) return;
|
||||||
console.log("Requesting keys down state");
|
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||||
|
|
||||||
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
// -32601 means the method is not supported
|
|
||||||
if (resp.error.code === RpcMethodNotFound) {
|
if (resp.error.code === RpcMethodNotFound) {
|
||||||
// if we don't support key down state, we know key press is also not available
|
console.warn(
|
||||||
console.warn("Failed to get key down state, switching to old-school", resp.error);
|
"Failed to get key down state, switching to old-school",
|
||||||
|
resp.error,
|
||||||
|
);
|
||||||
setHidRpcDisabled(true);
|
setHidRpcDisabled(true);
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to get key down state", resp.error);
|
console.error("Failed to get key down state", resp.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const downState = resp.result as KeysDownState;
|
const downState = resp.result as KeysDownState;
|
||||||
console.debug("Keyboard key down state", downState);
|
|
||||||
setKeysDownState(downState);
|
setKeysDownState(downState);
|
||||||
}
|
}
|
||||||
setNeedKeyDownState(false);
|
setNeedKeyDownState(false);
|
||||||
});
|
});
|
||||||
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
|
}, [
|
||||||
|
keysDownState,
|
||||||
|
needKeyDownState,
|
||||||
|
rpcDataChannel?.readyState,
|
||||||
|
send,
|
||||||
|
setKeysDownState,
|
||||||
|
setHidRpcDisabled,
|
||||||
|
hasPermission,
|
||||||
|
isLoadingPermissions,
|
||||||
|
]);
|
||||||
|
|
||||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -777,9 +964,11 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appVersion) return;
|
if (appVersion) return;
|
||||||
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
|
||||||
getLocalVersion();
|
getLocalVersion();
|
||||||
}, [appVersion, getLocalVersion]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [appVersion, rpcDataChannel?.readyState]);
|
||||||
|
|
||||||
const ConnectionStatusElement = useMemo(() => {
|
const ConnectionStatusElement = useMemo(() => {
|
||||||
const isOtherSession = location.pathname.includes("other-session");
|
const isOtherSession = location.pathname.includes("other-session");
|
||||||
|
|
@ -787,7 +976,9 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// Rebooting takes priority over connection status
|
// Rebooting takes priority over connection status
|
||||||
if (rebootState?.isRebooting) {
|
if (rebootState?.isRebooting) {
|
||||||
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
|
return (
|
||||||
|
<RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasConnectionFailed =
|
const hasConnectionFailed =
|
||||||
|
|
@ -814,87 +1005,188 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
|
}, [
|
||||||
|
location.pathname,
|
||||||
|
rebootState?.isRebooting,
|
||||||
|
rebootState?.postRebootAction,
|
||||||
|
connectionFailed,
|
||||||
|
peerConnectionState,
|
||||||
|
peerConnection,
|
||||||
|
setupPeerConnection,
|
||||||
|
loadingMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<PermissionsProvider>
|
||||||
{!outlet && otaState.updating && (
|
<FeatureFlagProvider appVersion={appVersion}>
|
||||||
<AnimatePresence>
|
{!outlet && otaState.updating && (
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -20 }}
|
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
>
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
<UpdateInProgressStatusCard />
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
<div className="relative h-full">
|
|
||||||
<FocusTrap
|
|
||||||
paused={disableVideoFocusTrap}
|
|
||||||
focusTrapOptions={{
|
|
||||||
allowOutsideClick: true,
|
|
||||||
escapeDeactivates: false,
|
|
||||||
fallbackFocus: "#videoFocusTrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0">
|
|
||||||
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
|
|
||||||
</div>
|
|
||||||
</FocusTrap>
|
|
||||||
|
|
||||||
<div className="grid h-full grid-rows-(--grid-headerBody) select-none">
|
|
||||||
<DashboardNavbar
|
|
||||||
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
|
|
||||||
showConnectionStatus={true}
|
|
||||||
isLoggedIn={authMode === "password" || !!user}
|
|
||||||
userEmail={user?.email}
|
|
||||||
picture={user?.picture}
|
|
||||||
kvmName={deviceName ?? "JetKVM Device"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
|
||||||
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
|
|
||||||
<div
|
|
||||||
style={{ animationDuration: "500ms" }}
|
|
||||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
|
||||||
>
|
>
|
||||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
<UpdateInProgressStatusCard />
|
||||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
</motion.div>
|
||||||
</div>
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
<div className="relative h-full">
|
||||||
|
<FocusTrap
|
||||||
|
paused={disableVideoFocusTrap}
|
||||||
|
focusTrapOptions={{
|
||||||
|
allowOutsideClick: true,
|
||||||
|
escapeDeactivates: false,
|
||||||
|
fallbackFocus: "#videoFocusTrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0">
|
||||||
|
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
|
||||||
|
<div className="grid h-full grid-rows-(--grid-headerBody) select-none">
|
||||||
|
<DashboardNavbar
|
||||||
|
primaryLinks={
|
||||||
|
isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]
|
||||||
|
}
|
||||||
|
showConnectionStatus={true}
|
||||||
|
isLoggedIn={authMode === "password" || !!user}
|
||||||
|
userEmail={user?.email}
|
||||||
|
picture={user?.picture}
|
||||||
|
kvmName={deviceName ?? "JetKVM Device"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
|
{/* Only show video feed if nickname is set (when required) and not pending approval */}
|
||||||
|
{!showNicknameModal && currentMode !== "pending" ? (
|
||||||
|
<>
|
||||||
|
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
|
||||||
|
<div
|
||||||
|
style={{ animationDuration: "500ms" }}
|
||||||
|
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||||
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-slate-900">
|
||||||
|
<div className="text-center text-slate-400">
|
||||||
|
{showNicknameModal && <p>Please set your nickname to continue</p>}
|
||||||
|
{currentMode === "pending" && <p>Waiting for session approval...</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SidebarContainer sidebarView={sidebarView} />
|
||||||
</div>
|
</div>
|
||||||
<SidebarContainer sidebarView={sidebarView} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="z-50"
|
className="z-50"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onMouseUp={e => e.stopPropagation()}
|
onMouseUp={e => e.stopPropagation()}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
onMouseDown={e => e.stopPropagation()}
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Escape") navigateTo("/");
|
if (e.key === "Escape") navigateTo("/");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||||
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||||
<Outlet context={{ setupPeerConnection }} />
|
<Outlet context={{ setupPeerConnection }} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
|
||||||
|
|
||||||
{kvmTerminal && (
|
<NicknameModal
|
||||||
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
|
isOpen={showNicknameModal}
|
||||||
)}
|
onSubmit={async nickname => {
|
||||||
|
setNickname(nickname);
|
||||||
|
setShowNicknameModal(false);
|
||||||
|
setDisableVideoFocusTrap(false);
|
||||||
|
|
||||||
{serialConsole && (
|
if (currentSessionId && send) {
|
||||||
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
try {
|
||||||
)}
|
await sessionApi.updateNickname(send, currentSessionId, nickname);
|
||||||
</FeatureFlagProvider>
|
} catch (error) {
|
||||||
|
console.error("Failed to update nickname:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSkip={() => {
|
||||||
|
setShowNicknameModal(false);
|
||||||
|
setDisableVideoFocusTrap(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kvmTerminal && (
|
||||||
|
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serialConsole && (
|
||||||
|
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unified Session Request Dialog */}
|
||||||
|
{(primaryControlRequest || newSessionRequest) && (
|
||||||
|
<UnifiedSessionRequestDialog
|
||||||
|
request={
|
||||||
|
primaryControlRequest
|
||||||
|
? {
|
||||||
|
id: primaryControlRequest.requestId,
|
||||||
|
type: "primary_control",
|
||||||
|
source: primaryControlRequest.source,
|
||||||
|
identity: primaryControlRequest.identity,
|
||||||
|
nickname: primaryControlRequest.nickname,
|
||||||
|
}
|
||||||
|
: newSessionRequest
|
||||||
|
? {
|
||||||
|
id: newSessionRequest.sessionId,
|
||||||
|
type: "session_approval",
|
||||||
|
source: newSessionRequest.source,
|
||||||
|
identity: newSessionRequest.identity,
|
||||||
|
nickname: newSessionRequest.nickname,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onApprove={
|
||||||
|
primaryControlRequest
|
||||||
|
? handleApprovePrimaryRequest
|
||||||
|
: handleApproveNewSession
|
||||||
|
}
|
||||||
|
onDeny={
|
||||||
|
primaryControlRequest ? handleDenyPrimaryRequest : handleDenyNewSession
|
||||||
|
}
|
||||||
|
onDismiss={
|
||||||
|
primaryControlRequest ? closePrimaryControlRequest : closeNewSessionRequest
|
||||||
|
}
|
||||||
|
onClose={
|
||||||
|
primaryControlRequest ? closePrimaryControlRequest : closeNewSessionRequest
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AccessDeniedOverlay
|
||||||
|
show={accessDenied}
|
||||||
|
message="Your session access was denied by the primary session"
|
||||||
|
onRequestApproval={async () => {
|
||||||
|
if (!send) return;
|
||||||
|
try {
|
||||||
|
await sessionApi.requestSessionApproval(send);
|
||||||
|
setAccessDenied(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to re-request approval:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PendingApprovalOverlay show={currentMode === "pending"} />
|
||||||
|
</FeatureFlagProvider>
|
||||||
|
</PermissionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type SessionMode = "primary" | "observer" | "queued" | "pending";
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
source: "local" | "cloud";
|
||||||
|
identity?: string;
|
||||||
|
nickname?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
// Current session info
|
||||||
|
currentSessionId: string | null;
|
||||||
|
currentMode: SessionMode | null;
|
||||||
|
|
||||||
|
// All active sessions
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isRequestingPrimary: boolean;
|
||||||
|
sessionError: string | null;
|
||||||
|
rejectionCount: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setCurrentSession: (id: string, mode: SessionMode) => void;
|
||||||
|
setSessions: (sessions: SessionInfo[]) => void;
|
||||||
|
setRequestingPrimary: (requesting: boolean) => void;
|
||||||
|
setSessionError: (error: string | null) => void;
|
||||||
|
updateSessionMode: (mode: SessionMode) => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
incrementRejectionCount: () => number;
|
||||||
|
resetRejectionCount: () => void;
|
||||||
|
|
||||||
|
// Computed getters
|
||||||
|
isPrimary: () => boolean;
|
||||||
|
isObserver: () => boolean;
|
||||||
|
isQueued: () => boolean;
|
||||||
|
isPending: () => boolean;
|
||||||
|
canRequestPrimary: () => boolean;
|
||||||
|
getPrimarySession: () => SessionInfo | undefined;
|
||||||
|
getQueuePosition: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
currentSessionId: null,
|
||||||
|
currentMode: null,
|
||||||
|
sessions: [],
|
||||||
|
isRequestingPrimary: false,
|
||||||
|
sessionError: null,
|
||||||
|
rejectionCount: 0,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setCurrentSession: (id: string, mode: SessionMode) => {
|
||||||
|
set({
|
||||||
|
currentSessionId: id,
|
||||||
|
currentMode: mode,
|
||||||
|
sessionError: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setSessions: (sessions: SessionInfo[]) => {
|
||||||
|
set({ sessions });
|
||||||
|
},
|
||||||
|
|
||||||
|
setRequestingPrimary: (requesting: boolean) => {
|
||||||
|
set({ isRequestingPrimary: requesting });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSessionError: (error: string | null) => {
|
||||||
|
set({ sessionError: error });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSessionMode: (mode: SessionMode) => {
|
||||||
|
set({ currentMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSession: () => {
|
||||||
|
set({
|
||||||
|
currentSessionId: null,
|
||||||
|
currentMode: null,
|
||||||
|
sessions: [],
|
||||||
|
sessionError: null,
|
||||||
|
isRequestingPrimary: false,
|
||||||
|
rejectionCount: 0
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
incrementRejectionCount: () => {
|
||||||
|
const newCount = get().rejectionCount + 1;
|
||||||
|
set({ rejectionCount: newCount });
|
||||||
|
return newCount;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetRejectionCount: () => {
|
||||||
|
set({ rejectionCount: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Computed getters
|
||||||
|
isPrimary: () => {
|
||||||
|
return get().currentMode === "primary";
|
||||||
|
},
|
||||||
|
|
||||||
|
isObserver: () => {
|
||||||
|
return get().currentMode === "observer";
|
||||||
|
},
|
||||||
|
|
||||||
|
isQueued: () => {
|
||||||
|
return get().currentMode === "queued";
|
||||||
|
},
|
||||||
|
|
||||||
|
isPending: () => {
|
||||||
|
return get().currentMode === "pending";
|
||||||
|
},
|
||||||
|
|
||||||
|
canRequestPrimary: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.currentMode === "observer" &&
|
||||||
|
!state.isRequestingPrimary &&
|
||||||
|
state.sessions.some(s => s.mode === "primary");
|
||||||
|
},
|
||||||
|
|
||||||
|
getPrimarySession: () => {
|
||||||
|
return get().sessions.find(s => s.mode === "primary");
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueuePosition: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.currentMode !== "queued") return -1;
|
||||||
|
|
||||||
|
const queuedSessions = state.sessions
|
||||||
|
.filter(s => s.mode === "queued")
|
||||||
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
|
||||||
|
return queuedSessions.findIndex(s => s.id === state.currentSessionId) + 1;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'session',
|
||||||
|
storage: createJSONStorage(() => sessionStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
currentSessionId: state.currentSessionId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared session store - separate with localStorage (shared across tabs)
|
||||||
|
// Used for user preferences that should be consistent across all tabs
|
||||||
|
export interface SharedSessionState {
|
||||||
|
nickname: string | null;
|
||||||
|
setNickname: (nickname: string | null) => void;
|
||||||
|
clearNickname: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSharedSessionStore = create<SharedSessionState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
nickname: null,
|
||||||
|
setNickname: (nickname: string | null) => set({ nickname }),
|
||||||
|
clearNickname: () => set({ nickname: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'sharedSession',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
export enum Permission {
|
||||||
|
VIDEO_VIEW = "video.view",
|
||||||
|
KEYBOARD_INPUT = "keyboard.input",
|
||||||
|
MOUSE_INPUT = "mouse.input",
|
||||||
|
PASTE = "clipboard.paste",
|
||||||
|
SESSION_TRANSFER = "session.transfer",
|
||||||
|
SESSION_APPROVE = "session.approve",
|
||||||
|
SESSION_KICK = "session.kick",
|
||||||
|
SESSION_REQUEST_PRIMARY = "session.request_primary",
|
||||||
|
SESSION_RELEASE_PRIMARY = "session.release_primary",
|
||||||
|
SESSION_MANAGE = "session.manage",
|
||||||
|
MOUNT_MEDIA = "mount.media",
|
||||||
|
UNMOUNT_MEDIA = "mount.unmedia",
|
||||||
|
MOUNT_LIST = "mount.list",
|
||||||
|
EXTENSION_MANAGE = "extension.manage",
|
||||||
|
EXTENSION_ATX = "extension.atx",
|
||||||
|
EXTENSION_DC = "extension.dc",
|
||||||
|
EXTENSION_SERIAL = "extension.serial",
|
||||||
|
EXTENSION_WOL = "extension.wol",
|
||||||
|
SETTINGS_READ = "settings.read",
|
||||||
|
SETTINGS_WRITE = "settings.write",
|
||||||
|
SETTINGS_ACCESS = "settings.access",
|
||||||
|
SYSTEM_REBOOT = "system.reboot",
|
||||||
|
SYSTEM_UPDATE = "system.update",
|
||||||
|
SYSTEM_NETWORK = "system.network",
|
||||||
|
POWER_CONTROL = "power.control",
|
||||||
|
USB_CONTROL = "usb.control",
|
||||||
|
TERMINAL_ACCESS = "terminal.access",
|
||||||
|
SERIAL_ACCESS = "serial.access",
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ export function callJsonRpc(options: JsonRpcCallOptions): Promise<JsonRpcCallRes
|
||||||
rpcDataChannel.removeEventListener("message", messageHandler);
|
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||||
resolve(response);
|
resolve(response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore parse errors from other messages
|
// Ignore parse errors from other messages
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Nickname generation using backend API for consistency
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
// Main function that uses backend generation
|
||||||
|
export async function generateNickname(sendFn?: RpcSendFunction): Promise<string> {
|
||||||
|
// Require backend function - no fallback
|
||||||
|
if (!sendFn) {
|
||||||
|
throw new Error('Backend connection required for nickname generation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const result = sendFn('generateNickname', { userAgent: navigator.userAgent }, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
const result = response.result as { nickname?: string } | undefined;
|
||||||
|
if (response && !response.error && result?.nickname) {
|
||||||
|
resolve(result.nickname);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to generate nickname from backend'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If sendFn returns undefined (RPC channel not ready), reject immediately
|
||||||
|
if (result === undefined) {
|
||||||
|
reject(new Error('RPC connection not ready yet'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous version removed - backend generation is always async
|
||||||
|
export function generateNicknameSync(): string {
|
||||||
|
throw new Error('Synchronous nickname generation not supported - use backend generateNickname()');
|
||||||
|
}
|
||||||
107
usb.go
107
usb.go
|
|
@ -27,20 +27,43 @@ func initUsbGadget() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
|
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
|
||||||
if currentSession != nil {
|
// Check if keystrokes should be private
|
||||||
currentSession.reportHidRPCKeyboardLedState(state)
|
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
|
||||||
|
// Report to primary session only
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.reportHidRPCKeyboardLedState(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Report to all authorized sessions (primary and observers, but not pending)
|
||||||
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
|
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
|
||||||
|
s.reportHidRPCKeyboardLedState(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
|
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
|
||||||
if currentSession != nil {
|
// Check if keystrokes should be private
|
||||||
currentSession.enqueueKeysDownState(state)
|
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
|
||||||
|
// Report to primary session only
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.enqueueKeysDownState(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Report to all authorized sessions (primary and observers, but not pending)
|
||||||
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
|
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
|
||||||
|
s.enqueueKeysDownState(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
gadget.SetOnKeepAliveReset(func() {
|
gadget.SetOnKeepAliveReset(func() {
|
||||||
if currentSession != nil {
|
// Reset keep-alive for primary session
|
||||||
currentSession.resetKeepAliveTime()
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.resetKeepAliveTime()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -50,26 +73,82 @@ func initUsbGadget() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcKeyboardReport(modifier byte, keys []byte) error {
|
func (s *Session) rpcKeyboardReport(modifier byte, keys []byte) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return ErrPermissionDeniedKeyboard
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.KeyboardReport(modifier, keys)
|
return gadget.KeyboardReport(modifier, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcKeypressReport(key byte, press bool) error {
|
func (s *Session) rpcKeypressReport(key byte, press bool) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return ErrPermissionDeniedKeyboard
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.KeypressReport(key, press)
|
return gadget.KeypressReport(key, press)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcAbsMouseReport(x int, y int, buttons uint8) error {
|
func (s *Session) rpcAbsMouseReport(x int, y int, buttons uint8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.AbsMouseReport(x, y, buttons)
|
return gadget.AbsMouseReport(x, y, buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
|
func (s *Session) rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.RelMouseReport(dx, dy, buttons)
|
return gadget.RelMouseReport(dx, dy, buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcWheelReport(wheelY int8) error {
|
func (s *Session) rpcWheelReport(wheelY int8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.AbsMouseWheelReport(wheelY)
|
return gadget.AbsMouseWheelReport(wheelY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC functions that route to the primary session
|
||||||
|
func rpcKeyboardReport(modifier byte, keys []byte) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcKeyboardReport(modifier, keys)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcKeypressReport(key byte, press bool) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcKeypressReport(key, press)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAbsMouseReport(x int, y int, buttons uint8) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcAbsMouseReport(x, y, buttons)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcRelMouseReport(dx, dy, buttons)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcWheelReport(wheelY int8) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcWheelReport(wheelY)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
|
func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
|
||||||
return gadget.GetKeyboardState()
|
return gadget.GetKeyboardState()
|
||||||
}
|
}
|
||||||
|
|
@ -89,11 +168,7 @@ func rpcGetUSBState() (state string) {
|
||||||
|
|
||||||
func triggerUSBStateUpdate() {
|
func triggerUSBStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
if currentSession == nil {
|
broadcastJSONRPCEvent("usbState", usbState)
|
||||||
usbLogger.Info().Msg("No active RPC session, skipping USB state update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONRPCEvent("usbState", usbState, currentSession)
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
video.go
2
video.go
|
|
@ -20,7 +20,7 @@ const (
|
||||||
|
|
||||||
func triggerVideoStateUpdate() {
|
func triggerVideoStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
writeJSONRPCEvent("videoInputState", lastVideoState, currentSession)
|
broadcastJSONRPCEvent("videoInputState", lastVideoState)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")
|
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")
|
||||||
|
|
|
||||||
206
web.go
206
web.go
|
|
@ -34,10 +34,25 @@ import (
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
type WebRTCSessionRequest struct {
|
type WebRTCSessionRequest struct {
|
||||||
Sd string `json:"sd"`
|
Sd string `json:"sd"`
|
||||||
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
SessionId string `json:"sessionId,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
||||||
ICEServers []string `json:"iceServers,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
|
ICEServers []string `json:"iceServers,omitempty"`
|
||||||
|
UserAgent string `json:"userAgent,omitempty"` // Browser user agent for nickname generation
|
||||||
|
SessionSettings *SessionSettings `json:"sessionSettings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSettings struct {
|
||||||
|
RequireApproval bool `json:"requireApproval"`
|
||||||
|
RequireNickname bool `json:"requireNickname"`
|
||||||
|
ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection
|
||||||
|
PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session
|
||||||
|
Nickname string `json:"nickname,omitempty"`
|
||||||
|
PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events
|
||||||
|
MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"` // Number of times denied session can retry before modal hides
|
||||||
|
MaxSessions int `json:"maxSessions,omitempty"` // Maximum number of concurrent sessions (default: 10)
|
||||||
|
ObserverTimeout int `json:"observerTimeout,omitempty"` // Time in seconds to wait before cleaning up inactive observer sessions (default: 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordRequest struct {
|
type SetPasswordRequest struct {
|
||||||
|
|
@ -158,32 +173,16 @@ func setupRouter() *gin.Engine {
|
||||||
protected := r.Group("/")
|
protected := r.Group("/")
|
||||||
protected.Use(protectedMiddleware())
|
protected.Use(protectedMiddleware())
|
||||||
{
|
{
|
||||||
/*
|
|
||||||
* Legacy WebRTC session endpoint
|
|
||||||
*
|
|
||||||
* This endpoint is maintained for backward compatibility when users upgrade from a version
|
|
||||||
* using the legacy HTTP-based signaling method to the new WebSocket-based signaling method.
|
|
||||||
*
|
|
||||||
* During the upgrade process, when the "Rebooting device after update..." message appears,
|
|
||||||
* the browser still runs the previous JavaScript code which polls this endpoint to establish
|
|
||||||
* a new WebRTC session. Once the session is established, the page will automatically reload
|
|
||||||
* with the updated code.
|
|
||||||
*
|
|
||||||
* Without this endpoint, the stale JavaScript would fail to establish a connection,
|
|
||||||
* causing users to see the "Rebooting device after update..." message indefinitely
|
|
||||||
* until they manually refresh the page, leading to a confusing user experience.
|
|
||||||
*/
|
|
||||||
protected.POST("/webrtc/session", handleWebRTCSession)
|
|
||||||
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
|
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
|
||||||
protected.POST("/cloud/register", handleCloudRegister)
|
protected.POST("/cloud/register", handleCloudRegister)
|
||||||
protected.GET("/cloud/state", handleCloudState)
|
protected.GET("/cloud/state", handleCloudState)
|
||||||
protected.GET("/device", handleDevice)
|
protected.GET("/device", handleDevice)
|
||||||
protected.POST("/auth/logout", handleLogout)
|
protected.POST("/auth/logout", handleLogout)
|
||||||
|
|
||||||
protected.POST("/auth/password-local", handleCreatePassword)
|
protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword)
|
||||||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword)
|
||||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword)
|
||||||
protected.POST("/storage/upload", handleUploadHttp)
|
protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all route for SPA
|
// Catch-all route for SPA
|
||||||
|
|
@ -198,44 +197,6 @@ func setupRouter() *gin.Engine {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: support multiple sessions?
|
|
||||||
var currentSession *Session
|
|
||||||
|
|
||||||
func handleWebRTCSession(c *gin.Context) {
|
|
||||||
var req WebRTCSessionRequest
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := newSession(SessionConfig{})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sd, err := session.ExchangeOffer(req.Sd)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentSession != nil {
|
|
||||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
|
||||||
peerConn := currentSession.peerConnection
|
|
||||||
go func() {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
_ = peerConn.Close()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any ongoing keyboard macro when session changes
|
|
||||||
cancelKeyboardMacro()
|
|
||||||
|
|
||||||
currentSession = session
|
|
||||||
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pingMessage = []byte("ping")
|
pingMessage = []byte("ping")
|
||||||
pongMessage = []byte("pong")
|
pongMessage = []byte("pong")
|
||||||
|
|
@ -244,7 +205,15 @@ var (
|
||||||
func handleLocalWebRTCSignal(c *gin.Context) {
|
func handleLocalWebRTCSignal(c *gin.Context) {
|
||||||
// get the source from the request
|
// get the source from the request
|
||||||
source := c.ClientIP()
|
source := c.ClientIP()
|
||||||
connectionID := uuid.New().String()
|
|
||||||
|
// Try to get existing session ID from cookie for session persistence
|
||||||
|
sessionID, _ := c.Cookie("sessionId")
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.New().String()
|
||||||
|
// Set session ID cookie with same expiry as auth token (7 days)
|
||||||
|
c.SetCookie("sessionId", sessionID, 7*24*60*60, "/", "", false, true)
|
||||||
|
}
|
||||||
|
connectionID := sessionID
|
||||||
|
|
||||||
scopedLogger := websocketLogger.With().
|
scopedLogger := websocketLogger.With().
|
||||||
Str("component", "websocket").
|
Str("component", "websocket").
|
||||||
|
|
@ -276,7 +245,17 @@ func handleLocalWebRTCSignal(c *gin.Context) {
|
||||||
// Now use conn for websocket operations
|
// Now use conn for websocket operations
|
||||||
defer wsCon.Close(websocket.StatusNormalClosure, "")
|
defer wsCon.Close(websocket.StatusNormalClosure, "")
|
||||||
|
|
||||||
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}})
|
// Include session settings in device metadata so client knows requirements upfront
|
||||||
|
sessionSettingsData := gin.H{
|
||||||
|
"deviceVersion": builtAppVersion,
|
||||||
|
}
|
||||||
|
if currentSessionSettings != nil {
|
||||||
|
sessionSettingsData["sessionSettings"] = gin.H{
|
||||||
|
"requireNickname": currentSessionSettings.RequireNickname,
|
||||||
|
"requireApproval": currentSessionSettings.RequireApproval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": sessionSettingsData})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -380,6 +359,13 @@ func handleWebRTCSignalWsMessages(
|
||||||
typ, msg, err := wsCon.Read(runCtx)
|
typ, msg, err := wsCon.Read(runCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("websocket read error")
|
l.Warn().Str("error", err.Error()).Msg("websocket read error")
|
||||||
|
// Clean up session when websocket closes
|
||||||
|
if session := sessionManager.GetSession(connectionID); session != nil && session.peerConnection != nil {
|
||||||
|
l.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Msg("Closing peer connection due to websocket error")
|
||||||
|
_ = session.peerConnection.Close()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if typ != websocket.MessageText {
|
if typ != websocket.MessageText {
|
||||||
|
|
@ -412,14 +398,17 @@ func handleWebRTCSignalWsMessages(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message")
|
||||||
|
|
||||||
if message.Type == "offer" {
|
if message.Type == "offer" {
|
||||||
l.Info().Msg("new session request received")
|
l.Info().Str("dataRaw", string(message.Data)).Msg("new session request received with raw data")
|
||||||
var req WebRTCSessionRequest
|
var req WebRTCSessionRequest
|
||||||
err = json.Unmarshal(message.Data, &req)
|
err = json.Unmarshal(message.Data, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("unable to parse session request data")
|
l.Warn().Str("error", err.Error()).Str("dataRaw", string(message.Data)).Msg("unable to parse session request data")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request")
|
||||||
|
|
||||||
if req.OidcGoogle != "" {
|
if req.OidcGoogle != "" {
|
||||||
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
|
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
|
||||||
|
|
@ -427,7 +416,7 @@ func handleWebRTCSignalWsMessages(
|
||||||
|
|
||||||
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
||||||
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
||||||
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l)
|
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, connectionID, &l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("error starting new session")
|
l.Warn().Str("error", err.Error()).Msg("error starting new session")
|
||||||
continue
|
continue
|
||||||
|
|
@ -449,14 +438,16 @@ func handleWebRTCSignalWsMessages(
|
||||||
|
|
||||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
|
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
|
||||||
|
|
||||||
if currentSession == nil {
|
// Find the session this ICE candidate belongs to using the connectionID
|
||||||
l.Warn().Msg("no current session, skipping incoming ICE candidate")
|
session := sessionManager.GetSession(connectionID)
|
||||||
|
if session == nil {
|
||||||
|
l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session")
|
l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session")
|
||||||
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
|
if err = session.peerConnection.AddICECandidate(candidate); err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection")
|
l.Warn().Str("error", err.Error()).Str("sessionID", session.ID).Msg("failed to add incoming ICE candidate to peer connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +472,16 @@ func handleLogin(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config.LocalAuthToken = uuid.New().String()
|
// Don't generate a new token - use the existing one
|
||||||
|
// This ensures all sessions can share the same auth token
|
||||||
|
if config.LocalAuthToken == "" {
|
||||||
|
// Only generate if we don't have one (shouldn't happen in normal operation)
|
||||||
|
config.LocalAuthToken = uuid.New().String()
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set the cookie
|
// Set the cookie
|
||||||
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
||||||
|
|
@ -490,14 +490,30 @@ func handleLogin(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogout(c *gin.Context) {
|
func handleLogout(c *gin.Context) {
|
||||||
config.LocalAuthToken = ""
|
// Get session ID from cookie before clearing
|
||||||
if err := SaveConfig(); err != nil {
|
sessionID, _ := c.Cookie("sessionId")
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
||||||
return
|
// Close the WebRTC session immediately for intentional logout
|
||||||
|
if sessionID != "" {
|
||||||
|
if session := sessionManager.GetSession(sessionID); session != nil {
|
||||||
|
websocketLogger.Info().
|
||||||
|
Str("sessionID", sessionID).
|
||||||
|
Msg("Closing session due to intentional logout - no grace period")
|
||||||
|
|
||||||
|
// Close peer connection (will trigger cleanupSession)
|
||||||
|
if session.peerConnection != nil {
|
||||||
|
_ = session.peerConnection.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear grace period for intentional logout - observer should be promoted immediately
|
||||||
|
sessionManager.ClearGracePeriod(sessionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the auth cookie
|
// Clear the cookies for this session, don't invalidate the token
|
||||||
|
// The token should remain valid for other sessions
|
||||||
c.SetCookie("authToken", "", -1, "/", "", false, true)
|
c.SetCookie("authToken", "", -1, "/", "", false, true)
|
||||||
|
c.SetCookie("sessionId", "", -1, "/", "", false, true)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
|
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,6 +535,38 @@ func protectedMiddleware() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requirePermissionMiddleware creates a middleware that enforces specific permissions
|
||||||
|
func requirePermissionMiddleware(permission Permission) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get session ID from cookie
|
||||||
|
sessionID, err := c.Cookie("sessionId")
|
||||||
|
if err != nil || sessionID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No session ID found"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session from manager
|
||||||
|
session := sessionManager.GetSession(sessionID)
|
||||||
|
if session == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session not found"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission
|
||||||
|
if !session.HasPermission(permission) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("Permission denied: %s required", permission)})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store session in context for use by handlers
|
||||||
|
c.Set("session", session)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendErrorJsonThenAbort(c *gin.Context, status int, message string) {
|
func sendErrorJsonThenAbort(c *gin.Context, status int, message string) {
|
||||||
c.JSON(status, gin.H{"error": message})
|
c.JSON(status, gin.H{"error": message})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
@ -591,7 +639,7 @@ func RunWebServer() {
|
||||||
|
|
||||||
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
|
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
|
||||||
if err := r.Run(bindAddress); err != nil {
|
if err := r.Run(bindAddress); err != nil {
|
||||||
panic(err)
|
logger.Fatal().Err(err).Msg("failed to start web server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ func runWebSecureServer() {
|
||||||
|
|
||||||
err := server.ListenAndServeTLS("", "")
|
err := server.ListenAndServeTLS("", "")
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
panic(err)
|
websecureLogger.Fatal().Err(err).Msg("failed to start websecure server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
270
webrtc.go
270
webrtc.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
|
|
@ -19,15 +20,42 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Predefined browser string constants for memory efficiency
|
||||||
|
var (
|
||||||
|
BrowserChrome = "chrome"
|
||||||
|
BrowserFirefox = "firefox"
|
||||||
|
BrowserSafari = "safari"
|
||||||
|
BrowserEdge = "edge"
|
||||||
|
BrowserOpera = "opera"
|
||||||
|
BrowserUnknown = "user"
|
||||||
|
)
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Mode SessionMode
|
||||||
|
Source string
|
||||||
|
Identity string
|
||||||
|
Nickname string
|
||||||
|
Browser *string // Pointer to predefined browser string constant for memory efficiency
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActive time.Time
|
||||||
|
LastBroadcast time.Time // Per-session broadcast throttle
|
||||||
|
|
||||||
|
// RPC rate limiting (DoS protection)
|
||||||
|
rpcRateLimitMu sync.Mutex // Protects rate limit fields
|
||||||
|
rpcRateLimit int // Count of RPCs in current window
|
||||||
|
rpcRateLimitWin time.Time // Start of current rate limit window
|
||||||
|
lastBroadcastMu sync.Mutex // Protects LastBroadcast field
|
||||||
|
|
||||||
peerConnection *webrtc.PeerConnection
|
peerConnection *webrtc.PeerConnection
|
||||||
VideoTrack *webrtc.TrackLocalStaticSample
|
VideoTrack *webrtc.TrackLocalStaticSample
|
||||||
ControlChannel *webrtc.DataChannel
|
ControlChannel *webrtc.DataChannel
|
||||||
RPCChannel *webrtc.DataChannel
|
RPCChannel *webrtc.DataChannel
|
||||||
HidChannel *webrtc.DataChannel
|
HidChannel *webrtc.DataChannel
|
||||||
shouldUmountVirtualMedia bool
|
shouldUmountVirtualMedia bool
|
||||||
|
flushCandidates func() // Callback to flush buffered ICE candidates
|
||||||
rpcQueue chan webrtc.DataChannelMessage
|
ws *websocket.Conn // WebSocket for critical signaling when RPC unavailable
|
||||||
|
rpcQueue chan webrtc.DataChannelMessage
|
||||||
|
|
||||||
hidRPCAvailable bool
|
hidRPCAvailable bool
|
||||||
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
|
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
|
||||||
|
|
@ -39,32 +67,38 @@ type Session struct {
|
||||||
keysDownStateQueue chan usbgadget.KeysDownState
|
keysDownStateQueue chan usbgadget.KeysDownState
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var actionSessions atomic.Int32
|
||||||
actionSessions int = 0
|
|
||||||
activeSessionsMutex = &sync.Mutex{}
|
|
||||||
)
|
|
||||||
|
|
||||||
func incrActiveSessions() int {
|
func incrActiveSessions() int32 {
|
||||||
activeSessionsMutex.Lock()
|
return actionSessions.Add(1)
|
||||||
defer activeSessionsMutex.Unlock()
|
|
||||||
|
|
||||||
actionSessions++
|
|
||||||
return actionSessions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrActiveSessions() int {
|
func getActiveSessions() int32 {
|
||||||
activeSessionsMutex.Lock()
|
return actionSessions.Load()
|
||||||
defer activeSessionsMutex.Unlock()
|
|
||||||
|
|
||||||
actionSessions--
|
|
||||||
return actionSessions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActiveSessions() int {
|
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
|
||||||
activeSessionsMutex.Lock()
|
func (s *Session) CheckRPCRateLimit() bool {
|
||||||
defer activeSessionsMutex.Unlock()
|
const (
|
||||||
|
maxRPCPerSecond = 500 // Increased to support 10+ concurrent sessions with broadcasts and state updates
|
||||||
|
rateLimitWindow = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
return actionSessions
|
s.rpcRateLimitMu.Lock()
|
||||||
|
defer s.rpcRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// Reset window if it has expired
|
||||||
|
if now.Sub(s.rpcRateLimitWin) > rateLimitWindow {
|
||||||
|
s.rpcRateLimit = 0
|
||||||
|
s.rpcRateLimitWin = now
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rpcRateLimit++
|
||||||
|
if s.rpcRateLimit > maxRPCPerSecond {
|
||||||
|
return false // Rate limit exceeded
|
||||||
|
}
|
||||||
|
return true // Within limits
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) resetKeepAliveTime() {
|
func (s *Session) resetKeepAliveTime() {
|
||||||
|
|
@ -74,6 +108,25 @@ func (s *Session) resetKeepAliveTime() {
|
||||||
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
|
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendWebSocketSignal sends critical state changes via WebSocket (fallback when RPC channel stale)
|
||||||
|
func (s *Session) sendWebSocketSignal(messageType string, data map[string]interface{}) error {
|
||||||
|
if s == nil || s.ws == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := wsjson.Write(ctx, s.ws, gin.H{"type": messageType, "data": data})
|
||||||
|
if err != nil {
|
||||||
|
webrtcLogger.Debug().Err(err).Str("sessionId", s.ID).Msg("Failed to send WebSocket signal")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
webrtcLogger.Info().Str("sessionId", s.ID).Str("messageType", messageType).Msg("Sent WebSocket signal")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type hidQueueMessage struct {
|
type hidQueueMessage struct {
|
||||||
webrtc.DataChannelMessage
|
webrtc.DataChannelMessage
|
||||||
channel string
|
channel string
|
||||||
|
|
@ -83,6 +136,7 @@ type SessionConfig struct {
|
||||||
ICEServers []string
|
ICEServers []string
|
||||||
LocalIP string
|
LocalIP string
|
||||||
IsCloud bool
|
IsCloud bool
|
||||||
|
UserAgent string // User agent for browser detection and nickname generation
|
||||||
ws *websocket.Conn
|
ws *websocket.Conn
|
||||||
Logger *zerolog.Logger
|
Logger *zerolog.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -246,7 +300,11 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &Session{peerConnection: peerConnection}
|
session := &Session{
|
||||||
|
peerConnection: peerConnection,
|
||||||
|
Browser: extractBrowserFromUserAgent(config.UserAgent),
|
||||||
|
ws: config.ws,
|
||||||
|
}
|
||||||
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
|
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
|
||||||
session.initQueues()
|
session.initQueues()
|
||||||
session.initKeysDownStateQueue()
|
session.initKeysDownStateQueue()
|
||||||
|
|
@ -283,16 +341,22 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
case "rpc":
|
case "rpc":
|
||||||
session.RPCChannel = d
|
session.RPCChannel = d
|
||||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
// Enqueue to ensure ordered processing
|
queueLen := len(session.rpcQueue)
|
||||||
|
if queueLen > 200 {
|
||||||
|
scopedLogger.Warn().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Int("queueLen", queueLen).
|
||||||
|
Msg("RPC queue approaching capacity")
|
||||||
|
}
|
||||||
session.rpcQueue <- msg
|
session.rpcQueue <- msg
|
||||||
})
|
})
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
triggerVideoStateUpdate()
|
triggerVideoStateUpdate()
|
||||||
triggerUSBStateUpdate()
|
triggerUSBStateUpdate()
|
||||||
case "terminal":
|
case "terminal":
|
||||||
handleTerminalChannel(d)
|
handleTerminalChannel(d, session)
|
||||||
case "serial":
|
case "serial":
|
||||||
handleSerialChannel(d)
|
handleSerialChannel(d, session)
|
||||||
default:
|
default:
|
||||||
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
|
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
|
||||||
go handleUploadChannel(d)
|
go handleUploadChannel(d)
|
||||||
|
|
@ -325,18 +389,116 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
}()
|
}()
|
||||||
var isConnected bool
|
var isConnected bool
|
||||||
|
|
||||||
|
// Buffer to hold ICE candidates until answer is sent
|
||||||
|
var candidateBuffer []webrtc.ICECandidateInit
|
||||||
|
var candidateBufferMutex sync.Mutex
|
||||||
|
var answerSent bool
|
||||||
|
|
||||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||||
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
|
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
|
||||||
if candidate != nil {
|
if candidate != nil {
|
||||||
err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()})
|
candidateBufferMutex.Lock()
|
||||||
|
if !answerSent {
|
||||||
|
// Buffer the candidate until answer is sent
|
||||||
|
candidateBuffer = append(candidateBuffer, candidate.ToJSON())
|
||||||
|
candidateBufferMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidateBufferMutex.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := wsjson.Write(ctx, config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
|
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Store the callback to flush buffered candidates
|
||||||
|
session.flushCandidates = func() {
|
||||||
|
candidateBufferMutex.Lock()
|
||||||
|
answerSent = true
|
||||||
|
// Send all buffered candidates
|
||||||
|
for _, candidate := range candidateBuffer {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err := wsjson.Write(ctx, config.ws, gin.H{"type": "new-ice-candidate", "data": candidate})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to write buffered new-ice-candidate to WebRTC signaling channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidateBuffer = nil
|
||||||
|
candidateBufferMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track cleanup state to prevent double cleanup
|
||||||
|
var cleanedUp bool
|
||||||
|
var cleanupMutex sync.Mutex
|
||||||
|
|
||||||
|
cleanupSession := func(reason string) {
|
||||||
|
cleanupMutex.Lock()
|
||||||
|
defer cleanupMutex.Unlock()
|
||||||
|
|
||||||
|
if cleanedUp {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanedUp = true
|
||||||
|
|
||||||
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("reason", reason).
|
||||||
|
Msg("Cleaning up session")
|
||||||
|
|
||||||
|
// Remove from session manager
|
||||||
|
sessionManager.RemoveSession(session.ID)
|
||||||
|
|
||||||
|
// Cancel any ongoing keyboard macro if session has permission
|
||||||
|
if session.HasPermission(PermissionPaste) {
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop RPC processor
|
||||||
|
if session.rpcQueue != nil {
|
||||||
|
close(session.rpcQueue)
|
||||||
|
session.rpcQueue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop HID RPC processor
|
||||||
|
for i := 0; i < len(session.hidQueue); i++ {
|
||||||
|
if session.hidQueue[i] != nil {
|
||||||
|
close(session.hidQueue[i])
|
||||||
|
session.hidQueue[i] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.keysDownStateQueue != nil {
|
||||||
|
close(session.keysDownStateQueue)
|
||||||
|
session.keysDownStateQueue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.shouldUmountVirtualMedia {
|
||||||
|
if err := rpcUnmountImage(); err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isConnected {
|
||||||
|
isConnected = false
|
||||||
|
newCount := actionSessions.Add(-1)
|
||||||
|
onActiveSessionsChanged()
|
||||||
|
if newCount == 0 {
|
||||||
|
onLastSessionDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||||
scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed")
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("connectionState", connectionState.String()).
|
||||||
|
Msg("ICE Connection State has changed")
|
||||||
|
|
||||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||||
if !isConnected {
|
if !isConnected {
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
|
@ -346,45 +508,27 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//state changes on closing browser tab disconnected->failed, we need to manually close it
|
|
||||||
|
// Handle disconnection and failure states
|
||||||
|
if connectionState == webrtc.ICEConnectionStateDisconnected {
|
||||||
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Msg("ICE Connection State is disconnected, connection may recover")
|
||||||
|
}
|
||||||
|
|
||||||
if connectionState == webrtc.ICEConnectionStateFailed {
|
if connectionState == webrtc.ICEConnectionStateFailed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection")
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Msg("ICE Connection State is failed, closing peerConnection and cleaning up")
|
||||||
|
cleanupSession("ice-failed")
|
||||||
_ = peerConnection.Close()
|
_ = peerConnection.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectionState == webrtc.ICEConnectionStateClosed {
|
if connectionState == webrtc.ICEConnectionStateClosed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
scopedLogger.Info().
|
||||||
if session == currentSession {
|
Str("sessionID", session.ID).
|
||||||
// Cancel any ongoing keyboard report multi when session closes
|
Msg("ICE Connection State is closed, cleaning up")
|
||||||
cancelKeyboardMacro()
|
cleanupSession("ice-closed")
|
||||||
currentSession = nil
|
|
||||||
}
|
|
||||||
// Stop RPC processor
|
|
||||||
if session.rpcQueue != nil {
|
|
||||||
close(session.rpcQueue)
|
|
||||||
session.rpcQueue = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop HID RPC processor
|
|
||||||
for i := 0; i < len(session.hidQueue); i++ {
|
|
||||||
close(session.hidQueue[i])
|
|
||||||
session.hidQueue[i] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
close(session.keysDownStateQueue)
|
|
||||||
session.keysDownStateQueue = nil
|
|
||||||
|
|
||||||
if session.shouldUmountVirtualMedia {
|
|
||||||
if err := rpcUnmountImage(); err != nil {
|
|
||||||
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isConnected {
|
|
||||||
isConnected = false
|
|
||||||
onActiveSessionsChanged()
|
|
||||||
if decrActiveSessions() == 0 {
|
|
||||||
onLastSessionDisconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return session, nil
|
return session, nil
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue