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)
|
||||
BUILDTS := $(shell date -u +%s)
|
||||
REVISION := $(shell git rev-parse HEAD)
|
||||
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.4.8
|
||||
VERSION_DEV ?= 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION ?= 0.4.8
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
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) {
|
||||
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
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -426,8 +440,15 @@ func handleSessionRequest(
|
|||
req WebRTCSessionRequest,
|
||||
isCloudConnection bool,
|
||||
source string,
|
||||
connectionID string,
|
||||
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
|
||||
if isCloudConnection {
|
||||
sourceType = "cloud"
|
||||
|
|
@ -453,6 +474,7 @@ func handleSessionRequest(
|
|||
IsCloud: isCloudConnection,
|
||||
LocalIP: req.IP,
|
||||
ICEServers: req.ICEServers,
|
||||
UserAgent: req.UserAgent,
|
||||
Logger: scopedLogger,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -462,26 +484,73 @@ func handleSessionRequest(
|
|||
|
||||
sd, err := session.ExchangeOffer(req.Sd)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("failed to exchange offer")
|
||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||
return err
|
||||
}
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
||||
peerConn := currentSession.peerConnection
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = peerConn.Close()
|
||||
}()
|
||||
session.Source = source
|
||||
|
||||
if isCloudConnection && req.OidcGoogle != "" {
|
||||
session.Identity = config.GoogleIdentity
|
||||
|
||||
// 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")
|
||||
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
||||
if sessionManager == nil {
|
||||
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
|
||||
cancelKeyboardMacro()
|
||||
err = sessionManager.AddSession(session, req.SessionSettings)
|
||||
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
|
||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||
if session.HasPermission(PermissionPaste) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
43
config.go
43
config.go
|
|
@ -78,11 +78,21 @@ func (m *KeyboardMacro) Validate() error {
|
|||
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 {
|
||||
CloudURL string `json:"cloud_url"`
|
||||
CloudAppURL string `json:"cloud_app_url"`
|
||||
CloudToken string `json:"cloud_token"`
|
||||
GoogleIdentity string `json:"google_identity"`
|
||||
MultiSession *MultiSessionConfig `json:"multi_session"`
|
||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
|
|
@ -105,6 +115,7 @@ type Config struct {
|
|||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||
NetworkConfig *types.NetworkConfig `json:"network_config"`
|
||||
DefaultLogLevel string `json:"default_log_level"`
|
||||
SessionSettings *SessionSettings `json:"session_settings"`
|
||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||
VideoQualityFactor float64 `json:"video_quality_factor"`
|
||||
}
|
||||
|
|
@ -156,17 +167,31 @@ var (
|
|||
|
||||
func getDefaultConfig() Config {
|
||||
return Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
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{},
|
||||
DisplayRotation: "270",
|
||||
KeyboardLayout: "en-US",
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 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
|
||||
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
|
||||
TLSMode: "",
|
||||
|
|
@ -248,6 +273,14 @@ func LoadConfig() {
|
|||
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
|
||||
if 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.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() {
|
||||
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() {
|
||||
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()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to marshal handshake message")
|
||||
|
|
@ -27,8 +34,18 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
|||
}
|
||||
session.hidRPCAvailable = true
|
||||
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)
|
||||
case hidrpc.TypeKeyboardMacroReport:
|
||||
if !session.HasPermission(PermissionPaste) {
|
||||
return
|
||||
}
|
||||
keyboardMacroReport, err := message.KeyboardMacroReport()
|
||||
if err != nil {
|
||||
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)
|
||||
case hidrpc.TypeCancelKeyboardMacroReport:
|
||||
if !session.HasPermission(PermissionPaste) {
|
||||
return
|
||||
}
|
||||
rpcCancelKeyboardMacro()
|
||||
return
|
||||
case hidrpc.TypeKeypressKeepAliveReport:
|
||||
if !session.HasPermission(PermissionKeyboardInput) {
|
||||
return
|
||||
}
|
||||
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
||||
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()
|
||||
if err != nil {
|
||||
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)
|
||||
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()
|
||||
if err != nil {
|
||||
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
|
||||
|
||||
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()
|
||||
defer session.keepAliveJitterLock.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
|
||||
// (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.
|
||||
// Staleness guard: discard ancient packets after network stall/machine sleep
|
||||
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
|
||||
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"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -23,6 +24,40 @@ import (
|
|||
"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 {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
|
|
@ -54,11 +89,16 @@ type BacklightSettings struct {
|
|||
}
|
||||
|
||||
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||
if session == nil || session.RPCChannel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
||||
return
|
||||
}
|
||||
|
||||
err = session.RPCChannel.SendText(string(responseBytes))
|
||||
if err != nil {
|
||||
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) {
|
||||
// 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{
|
||||
JSONRPC: "2.0",
|
||||
Method: event,
|
||||
|
|
@ -77,10 +122,6 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
|||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
||||
return
|
||||
}
|
||||
if session == nil || session.RPCChannel == nil {
|
||||
jsonRpcLogger.Info().Msg("RPC channel not available")
|
||||
return
|
||||
}
|
||||
|
||||
requestString := string(requestBytes)
|
||||
scopedLogger := jsonRpcLogger.With().
|
||||
|
|
@ -91,12 +132,53 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
|
|||
|
||||
err = session.RPCChannel.SendText(requestString)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastJSONRPCEvent(event string, params any) {
|
||||
sessionManager.ForEachSession(func(s *Session) {
|
||||
writeJSONRPCEvent(event, params, s)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
err := json.Unmarshal(message.Data, &request)
|
||||
if err != nil {
|
||||
|
|
@ -124,21 +206,62 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
|
||||
scopedLogger.Trace().Msg("Received RPC request")
|
||||
|
||||
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,
|
||||
var result any
|
||||
var handlerErr error
|
||||
|
||||
// Handle session management RPC methods
|
||||
switch request.Method {
|
||||
case "approvePrimaryRequest", "denyPrimaryRequest":
|
||||
result, handlerErr = handleSessionTransferRPC(request.Method, request.Params, session)
|
||||
case "approveNewSession", "denyNewSession":
|
||||
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 {
|
||||
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
||||
errorResponse := JSONRPCResponse{
|
||||
|
|
@ -154,7 +277,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
return
|
||||
}
|
||||
|
||||
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
|
||||
scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully")
|
||||
|
||||
response := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
|
|
@ -175,7 +298,7 @@ func rpcGetDeviceID() (string, error) {
|
|||
func rpcReboot(force bool) error {
|
||||
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
|
||||
|
||||
writeJSONRPCEvent("willReboot", nil, currentSession)
|
||||
broadcastJSONRPCEvent("willReboot", nil)
|
||||
|
||||
// Wait for the JSONRPCEvent to be sent
|
||||
time.Sleep(1 * time.Second)
|
||||
|
|
@ -1088,6 +1211,78 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
|||
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 (
|
||||
keyboardMacroCancel context.CancelFunc
|
||||
keyboardMacroLock sync.Mutex
|
||||
|
|
@ -1123,8 +1318,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
|||
IsPaste: true,
|
||||
}
|
||||
|
||||
if currentSession != nil {
|
||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||
// Report to primary session if exists
|
||||
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||
}
|
||||
|
||||
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
||||
|
|
@ -1132,8 +1328,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
|||
setKeyboardMacroCancel(nil)
|
||||
|
||||
s.State = false
|
||||
if currentSession != nil {
|
||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||
}
|
||||
|
||||
return err
|
||||
|
|
@ -1273,4 +1469,10 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"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() {
|
||||
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
|
||||
appCtx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
|
@ -92,7 +108,8 @@ func Main() {
|
|||
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")
|
||||
time.Sleep(1 * time.Minute)
|
||||
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) {
|
||||
if currentSession != nil {
|
||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
|
||||
if err != nil {
|
||||
nativeLogger.Warn().Err(err).Msg("error writing sample")
|
||||
sessionManager.ForEachSession(func(s *Session) {
|
||||
if !sessionManager.CanReceiveVideo(s, currentSessionSettings) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
|
||||
}
|
||||
broadcastJSONRPCEvent("networkState", state.ToRpcInterfaceState())
|
||||
|
||||
if state.Online {
|
||||
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 rebootRequired {
|
||||
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)
|
||||
|
|
|
|||
6
ota.go
6
ota.go
|
|
@ -302,11 +302,7 @@ var otaState = OTAState{}
|
|||
|
||||
func triggerOTAStateUpdate() {
|
||||
go func() {
|
||||
if currentSession == nil {
|
||||
logger.Info().Msg("No active RPC session, skipping update state update")
|
||||
return
|
||||
}
|
||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
||||
broadcastJSONRPCEvent("otaState", otaState)
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
|
|||
25
serial.go
25
serial.go
|
|
@ -57,12 +57,10 @@ func runATXControl() {
|
|||
newBtnRSTState := line[2] == '1'
|
||||
newBtnPWRState := line[3] == '1'
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("atxState", ATXState{
|
||||
Power: newLedPWRState,
|
||||
HDD: newLedHDDState,
|
||||
}, currentSession)
|
||||
}
|
||||
broadcastJSONRPCEvent("atxState", ATXState{
|
||||
Power: newLedPWRState,
|
||||
HDD: newLedHDDState,
|
||||
})
|
||||
|
||||
if newLedHDDState != ledHDDState ||
|
||||
newLedPWRState != ledPWRState ||
|
||||
|
|
@ -210,9 +208,7 @@ func runDCControl() {
|
|||
// Update Prometheus metrics
|
||||
updateDCMetrics(dcState)
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("dcState", dcState, currentSession)
|
||||
}
|
||||
broadcastJSONRPCEvent("dcState", dcState)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,9 +280,16 @@ func reopenSerialPort() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleSerialChannel(d *webrtc.DataChannel) {
|
||||
func handleSerialChannel(d *webrtc.DataChannel, session *Session) {
|
||||
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() {
|
||||
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"`
|
||||
}
|
||||
|
||||
func handleTerminalChannel(d *webrtc.DataChannel) {
|
||||
func handleTerminalChannel(d *webrtc.DataChannel, session *Session) {
|
||||
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 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 { FaKeyboard } from "react-icons/fa6";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
import { Fragment, useCallback, useRef, useEffect } from "react";
|
||||
import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import {
|
||||
|
|
@ -11,14 +11,18 @@ import {
|
|||
useMountMediaStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
} from "@/hooks/stores";
|
||||
useRTCStore } from "@/hooks/stores";
|
||||
import Container from "@components/Container";
|
||||
import { cx } from "@/cva.config";
|
||||
import PasteModal from "@/components/popovers/PasteModal";
|
||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||
import MountPopopover from "@/components/popovers/MountPopover";
|
||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||
import SessionPopover from "@/components/popovers/SessionPopover";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Permission } from "@/types/permissions";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
|
|
@ -33,6 +37,40 @@ export default function Actionbar({
|
|||
state => state.remoteVirtualMediaState,
|
||||
);
|
||||
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
|
||||
// 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) {
|
||||
setTimeout(() => {
|
||||
setDisableVideoFocusTrap(false);
|
||||
console.debug("Popover is closing. Returning focus trap to video");
|
||||
}, 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"
|
||||
>
|
||||
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
{developerMode && (
|
||||
{developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
|
|
@ -69,179 +106,239 @@ export default function Actionbar({
|
|||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||
/>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
{hasPermission(Permission.PASTE) && (
|
||||
<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
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Paste text"
|
||||
LeadingIcon={MdOutlineContentPasteGo}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
{/* Session Control */}
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Virtual Media"
|
||||
text={sessions.length > 0 ? `Sessions (${sessions.length})` : "Sessions"}
|
||||
LeadingIcon={({ className }) => {
|
||||
return (
|
||||
<>
|
||||
<LuHardDrive className={className} />
|
||||
<div
|
||||
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
|
||||
hidden: !remoteVirtualMediaState,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const modeColor = currentMode === "primary" ? "text-green-500" :
|
||||
currentMode === "observer" ? "text-blue-500" :
|
||||
currentMode === "queued" ? "text-yellow-500" :
|
||||
"text-slate-500";
|
||||
return <UserGroupIcon className={cx(className, modeColor)} />;
|
||||
}}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
anchor="bottom start"
|
||||
anchor="bottom end"
|
||||
transition
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{({ open }) => {
|
||||
checkIfStateChanged(open);
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<MountPopopover />
|
||||
</div>
|
||||
);
|
||||
return <SessionPopover />;
|
||||
}}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
{hasPermission(Permission.EXTENSION_MANAGE) && (
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Wake on LAN"
|
||||
text="Extension"
|
||||
LeadingIcon={LuCable}
|
||||
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!",
|
||||
"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 (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<WakeOnLanModal />
|
||||
</div>
|
||||
);
|
||||
return <ExtensionPopover />;
|
||||
}}
|
||||
</PopoverPanel>
|
||||
</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">
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
||||
<div className="block lg:hidden">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Extension"
|
||||
LeadingIcon={LuCable}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||
/>
|
||||
</PopoverButton>
|
||||
<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>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
size="XS"
|
||||
|
|
@ -258,18 +355,21 @@ export default function Actionbar({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
navigateTo("/settings")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Only show Settings for sessions with settings access */}
|
||||
{hasPermission(Permission.SETTINGS_ACCESS) && (
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
navigateTo("/settings")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden items-center gap-x-2 lg:flex">
|
||||
<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 PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||
|
||||
import api from "../api";
|
||||
import { isOnDevice } from "../main";
|
||||
|
|
@ -37,6 +38,8 @@ export default function DashboardNavbar({
|
|||
}: NavbarProps) {
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const { clearSession } = useSessionStore();
|
||||
const { clearNickname } = useSharedSessionStore();
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||
|
|
@ -44,9 +47,12 @@ export default function DashboardNavbar({
|
|||
if (!res.ok) return;
|
||||
|
||||
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
|
||||
navigate("/");
|
||||
}, [navigate, setUser]);
|
||||
}, [navigate, setUser, clearNickname, clearSession]);
|
||||
|
||||
const { usbState } = useHidStore();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +6,26 @@ import Container from "@components/Container";
|
|||
import { useMacrosStore } from "@/hooks/stores";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Permission } from "@/types/permissions";
|
||||
|
||||
export default function MacroBar() {
|
||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||
const { executeMacro } = useKeyboard();
|
||||
const { send } = useJsonRpc();
|
||||
const { permissions, hasPermission } = usePermissions();
|
||||
|
||||
useEffect(() => {
|
||||
setSendFn(send);
|
||||
|
||||
if (!initialized) {
|
||||
// Only load macros if user has permission to read settings
|
||||
if (!initialized && permissions[Permission.SETTINGS_READ] === true) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Permission } from "@/types/permissions";
|
||||
import useMouse from "@/hooks/useMouse";
|
||||
|
||||
import {
|
||||
|
|
@ -35,6 +37,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
const { hasPermission } = usePermissions();
|
||||
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
||||
const {
|
||||
getRelMouseMoveHandler,
|
||||
|
|
@ -214,29 +217,47 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, [releaseKeyboardLock]);
|
||||
|
||||
const absMouseMoveHandler = useMemo(
|
||||
() => getAbsMouseMoveHandler({
|
||||
const absMouseMoveHandler = useMemo(() => {
|
||||
const handler = getAbsMouseMoveHandler({
|
||||
videoClientWidth,
|
||||
videoClientHeight,
|
||||
videoWidth,
|
||||
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(
|
||||
() => getRelMouseMoveHandler(),
|
||||
[getRelMouseMoveHandler],
|
||||
);
|
||||
const relMouseMoveHandler = useMemo(() => {
|
||||
const handler = 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(
|
||||
() => getMouseWheelHandler(),
|
||||
[getMouseWheelHandler],
|
||||
);
|
||||
const mouseWheelHandler = useMemo(() => {
|
||||
const handler = 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(
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only allow input if user has keyboard permission
|
||||
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) return;
|
||||
const code = getAdjustedKeyCode(e);
|
||||
const hidKey = keys[code];
|
||||
|
|
@ -252,11 +273,9 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
||||
if (e.metaKey && hidKey < 0xE0) {
|
||||
setTimeout(() => {
|
||||
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
|
||||
handleKeyPress(hidKey, false);
|
||||
}, 10);
|
||||
}
|
||||
console.debug(`Key down: ${hidKey}`);
|
||||
handleKeyPress(hidKey, true);
|
||||
|
||||
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
|
||||
// focus so set a deferred keyup after a short delay
|
||||
setTimeout(() => {
|
||||
console.debug(`Forcing the left meta key release`);
|
||||
handleKeyPress(hidKey, false);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[handleKeyPress, isKeyboardLockActive],
|
||||
[handleKeyPress, isKeyboardLockActive, hasPermission],
|
||||
);
|
||||
|
||||
const keyUpHandler = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only allow input if user has keyboard permission
|
||||
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = getAdjustedKeyCode(e);
|
||||
const hidKey = keys[code];
|
||||
|
||||
|
|
@ -283,10 +307,9 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
return;
|
||||
}
|
||||
|
||||
console.debug(`Key up: ${hidKey}`);
|
||||
handleKeyPress(hidKey, false);
|
||||
},
|
||||
[handleKeyPress],
|
||||
[handleKeyPress, hasPermission],
|
||||
);
|
||||
|
||||
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||
|
|
@ -297,7 +320,6 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
// Fix only works in chrome based browsers.
|
||||
if (e.code === "Space") {
|
||||
if (videoElm.current.paused) {
|
||||
console.debug("Force playing video");
|
||||
videoElm.current.play();
|
||||
}
|
||||
}
|
||||
|
|
@ -557,7 +579,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualKeyboard />
|
||||
{hasPermission(Permission.KEYBOARD_INPUT) && <VirtualKeyboard />}
|
||||
</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;
|
||||
setDeveloperMode: (enabled: boolean) => void;
|
||||
|
||||
requireSessionNickname: boolean;
|
||||
setRequireSessionNickname: (required: boolean) => void;
|
||||
|
||||
requireSessionApproval: boolean;
|
||||
setRequireSessionApproval: (required: boolean) => void;
|
||||
|
||||
maxRejectionAttempts: number;
|
||||
setMaxRejectionAttempts: (attempts: number) => void;
|
||||
|
||||
displayRotation: string;
|
||||
setDisplayRotation: (rotation: string) => void;
|
||||
|
||||
|
|
@ -383,6 +392,15 @@ export const useSettingsStore = create(
|
|||
developerMode: false,
|
||||
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",
|
||||
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";
|
||||
|
||||
|
|
@ -36,6 +36,12 @@ let requestCounter = 0;
|
|||
|
||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const onRequestRef = useRef(onRequest);
|
||||
|
||||
// Update ref when callback changes
|
||||
useEffect(() => {
|
||||
onRequestRef.current = onRequest;
|
||||
}, [onRequest]);
|
||||
|
||||
const send = useCallback(
|
||||
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
|
||||
// If the payload has a method, it's a request
|
||||
if ("method" in payload) {
|
||||
if (onRequest) onRequest(payload);
|
||||
if (onRequestRef.current) onRequestRef.current(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +85,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||
};
|
||||
},
|
||||
[rpcDataChannel, onRequest]);
|
||||
[rpcDataChannel]); // Remove onRequest from dependencies
|
||||
|
||||
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 SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
|
||||
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 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 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";
|
||||
|
||||
|
|
@ -57,6 +62,32 @@ const notifications = {
|
|||
{ 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) {
|
||||
|
|
@ -82,7 +113,12 @@ export function Notifications({
|
|||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export default Object.assign(Notifications, {
|
||||
export const notify = {
|
||||
success: notifications.success,
|
||||
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);
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
|
||||
}, [send, getCloudState, getTLSState]);
|
||||
|
||||
return (
|
||||
|
|
@ -327,6 +328,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsSectionHeader
|
||||
title="Remote"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
|||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Permission } from "@/types/permissions";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
|
||||
import notifications from "../notifications";
|
||||
|
|
@ -17,6 +19,7 @@ export default function SettingsHardwareRoute() {
|
|||
const { send } = useJsonRpc();
|
||||
const settings = useSettingsStore();
|
||||
const { setDisplayRotation } = useSettingsStore();
|
||||
const { hasPermission, isLoading, permissions } = usePermissions();
|
||||
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
|
||||
|
||||
const handleDisplayRotationChange = (rotation: string) => {
|
||||
|
|
@ -77,16 +80,19 @@ export default function SettingsHardwareRoute() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
||||
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);
|
||||
});
|
||||
}, [send, setBacklightSettings]);
|
||||
// Only fetch settings if user has permission
|
||||
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
|
||||
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}, [send, setBacklightSettings, isLoading, permissions]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
|
||||
|
|
@ -99,6 +105,24 @@ export default function SettingsHardwareRoute() {
|
|||
});
|
||||
}, [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 (
|
||||
<div className="space-y-4">
|
||||
<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 { NavLink, Outlet, useLocation } from "react-router";
|
||||
import { NavLink, Outlet, useLocation , useNavigate } from "react-router";
|
||||
import {
|
||||
LuSettings,
|
||||
LuMouse,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LuPalette,
|
||||
LuCommand,
|
||||
LuNetwork,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
|
||||
|
|
@ -20,11 +21,24 @@ import Card from "@components/Card";
|
|||
import { LinkButton } from "@components/Button";
|
||||
import { FeatureFlag } from "@components/FeatureFlag";
|
||||
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. */
|
||||
export default function SettingsRoute() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
|
|
@ -69,6 +83,21 @@ export default function SettingsRoute() {
|
|||
};
|
||||
}, [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 (
|
||||
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
||||
<div className="h-full">
|
||||
|
|
@ -223,6 +252,17 @@ export default function SettingsRoute() {
|
|||
</div>
|
||||
</NavLink>
|
||||
</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">
|
||||
<NavLink
|
||||
to="advanced"
|
||||
|
|
|
|||
|
|
@ -30,18 +30,30 @@ import {
|
|||
useNetworkStateStore,
|
||||
User,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
useVideoStore,
|
||||
VideoState,
|
||||
} from "@/hooks/stores";
|
||||
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";
|
||||
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
||||
const Terminal = lazy(() => import('@components/Terminal'));
|
||||
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
|
||||
const ConnectionStatsSidebar = lazy(() => import("@/components/sidebar/connectionStats"));
|
||||
const Terminal = lazy(() => import("@components/Terminal"));
|
||||
const UpdateInProgressStatusCard = lazy(
|
||||
() => import("@/components/UpdateInProgressStatusCard"),
|
||||
);
|
||||
import Modal from "@/components/Modal";
|
||||
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
RpcMethodNotFound,
|
||||
useJsonRpc,
|
||||
} from "@/hooks/useJsonRpc";
|
||||
import {
|
||||
ConnectionFailedOverlay,
|
||||
LoadingConnectionOverlay,
|
||||
|
|
@ -50,8 +62,14 @@ import {
|
|||
} from "@/components/VideoOverlay";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
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 { useVersion } from "@/hooks/useVersion";
|
||||
import { useSessionManagement } from "@/hooks/useSessionManagement";
|
||||
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||
import { sessionApi } from "@/api/sessionApi";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
|
|
@ -124,15 +142,25 @@ export default function KvmIdRoute() {
|
|||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||
|
||||
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 {
|
||||
peerConnection, setPeerConnection,
|
||||
peerConnectionState, setPeerConnectionState,
|
||||
peerConnection,
|
||||
setPeerConnection,
|
||||
peerConnectionState,
|
||||
setPeerConnectionState,
|
||||
setMediaStream,
|
||||
setRpcDataChannel,
|
||||
isTurnServerInUse, setTurnServerInUse,
|
||||
isTurnServerInUse,
|
||||
setTurnServerInUse,
|
||||
rpcDataChannel,
|
||||
setTransceiver,
|
||||
setRpcHidChannel,
|
||||
|
|
@ -143,15 +171,22 @@ export default function KvmIdRoute() {
|
|||
const location = useLocation();
|
||||
const isLegacySignalingEnabled = useRef(false);
|
||||
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||
const [showNicknameModal, setShowNicknameModal] = useState(false);
|
||||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
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 cleanupAndStopReconnecting = useCallback(
|
||||
function cleanupAndStopReconnecting() {
|
||||
console.log("Closing peer connection");
|
||||
|
||||
setConnectionFailed(true);
|
||||
if (peerConnection) {
|
||||
setPeerConnectionState(peerConnection.connectionState);
|
||||
|
|
@ -188,7 +223,6 @@ export default function KvmIdRoute() {
|
|||
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||
console.log("[setRemoteSessionDescription] Remote description set successfully");
|
||||
setLoadingMessage("Establishing secure connection...");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
@ -206,7 +240,6 @@ export default function KvmIdRoute() {
|
|||
|
||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||
if (pc.sctp?.state === "connected") {
|
||||
console.log("[setRemoteSessionDescription] Remote description set");
|
||||
clearInterval(checkInterval);
|
||||
setLoadingMessage("Connection established");
|
||||
} else if (attempts >= 10) {
|
||||
|
|
@ -219,11 +252,6 @@ export default function KvmIdRoute() {
|
|||
);
|
||||
cleanupAndStopReconnecting();
|
||||
clearInterval(checkInterval);
|
||||
} else {
|
||||
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
|
@ -246,7 +274,6 @@ export default function KvmIdRoute() {
|
|||
reconnectAttempts: 2000,
|
||||
reconnectInterval: 1000,
|
||||
onReconnectStop: () => {
|
||||
console.debug("Reconnect stopped");
|
||||
cleanupAndStopReconnecting();
|
||||
},
|
||||
|
||||
|
|
@ -255,9 +282,8 @@ export default function KvmIdRoute() {
|
|||
return !isLegacySignalingEnabled.current;
|
||||
},
|
||||
|
||||
onClose(event) {
|
||||
console.debug("[Websocket] onClose", event);
|
||||
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||
onClose(_event) {
|
||||
// Handled by onReconnectStop instead
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
|
|
@ -296,27 +322,51 @@ export default function KvmIdRoute() {
|
|||
|
||||
const parsedMessage = JSON.parse(message.data);
|
||||
if (parsedMessage.type === "device-metadata") {
|
||||
const { deviceVersion } = parsedMessage.data;
|
||||
console.debug("[Websocket] Received device-metadata message");
|
||||
console.debug("[Websocket] Device version", deviceVersion);
|
||||
const { deviceVersion, sessionSettings } = parsedMessage.data;
|
||||
|
||||
// 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 (!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
|
||||
// which does everything over HTTP(at least from the perspective of the client)
|
||||
isLegacySignalingEnabled.current = true;
|
||||
getWebSocket()?.close();
|
||||
} else {
|
||||
console.log("[Websocket] Device is using new signaling");
|
||||
isLegacySignalingEnabled.current = false;
|
||||
}
|
||||
|
||||
// Always setup peer connection first to establish RPC channel for nickname generation
|
||||
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") {
|
||||
console.debug("[Websocket] Received answer");
|
||||
const readyForOffer =
|
||||
// If we're making an offer, we don't want to accept an answer
|
||||
!makingOffer &&
|
||||
|
|
@ -330,14 +380,46 @@ export default function KvmIdRoute() {
|
|||
|
||||
// Set so we don't accept an answer while we're setting the remote description
|
||||
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
||||
console.debug(
|
||||
"[Websocket] Setting remote answer pending",
|
||||
isSettingRemoteAnswerPending.current,
|
||||
);
|
||||
|
||||
const sd = atob(parsedMessage.data);
|
||||
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(
|
||||
peerConnection,
|
||||
new RTCSessionDescription(remoteSessionDescription),
|
||||
|
|
@ -346,21 +428,63 @@ export default function KvmIdRoute() {
|
|||
// Reset the remote answer pending flag
|
||||
isSettingRemoteAnswerPending.current = false;
|
||||
} else if (parsedMessage.type === "new-ice-candidate") {
|
||||
console.debug("[Websocket] Received new-ice-candidate");
|
||||
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(
|
||||
(type: string, data: unknown) => {
|
||||
// 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.
|
||||
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(
|
||||
|
|
@ -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
|
||||
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||
|
||||
console.log("Trying to get remote session description");
|
||||
setLoadingMessage(
|
||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||
);
|
||||
const res = await api.POST(sessionUrl, {
|
||||
sd,
|
||||
userAgent: navigator.userAgent,
|
||||
// When on device, we don't need to specify the device id, as it's already known
|
||||
...(isOnDevice ? {} : { id: params.id }),
|
||||
});
|
||||
|
|
@ -389,7 +513,6 @@ export default function KvmIdRoute() {
|
|||
return;
|
||||
}
|
||||
|
||||
console.debug("Successfully got Remote Session Description. Setting.");
|
||||
setLoadingMessage("Setting remote session description...");
|
||||
|
||||
const decodedSd = atob(json.sd);
|
||||
|
|
@ -400,13 +523,11 @@ export default function KvmIdRoute() {
|
|||
);
|
||||
|
||||
const setupPeerConnection = useCallback(async () => {
|
||||
console.debug("[setupPeerConnection] Setting up peer connection");
|
||||
setConnectionFailed(false);
|
||||
setLoadingMessage("Connecting to device...");
|
||||
|
||||
let pc: RTCPeerConnection;
|
||||
try {
|
||||
console.debug("[setupPeerConnection] Creating peer connection");
|
||||
setLoadingMessage("Creating peer connection...");
|
||||
pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
|
|
@ -416,7 +537,6 @@ export default function KvmIdRoute() {
|
|||
});
|
||||
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
console.debug("[setupPeerConnection] Peer connection created", pc);
|
||||
setLoadingMessage("Setting up connection to device...");
|
||||
} catch (e) {
|
||||
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
||||
|
|
@ -428,13 +548,11 @@ export default function KvmIdRoute() {
|
|||
|
||||
// Set up event listeners and data channels
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
};
|
||||
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
console.debug("[setupPeerConnection] Creating offer");
|
||||
makingOffer.current = true;
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
|
|
@ -442,9 +560,19 @@ export default function KvmIdRoute() {
|
|||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||
if (isNewSignalingEnabled) {
|
||||
sendWebRTCSignal("offer", { sd: sd });
|
||||
} else {
|
||||
console.log("Legacy signaling. Waiting for ICE Gathering to complete...");
|
||||
// Get nickname and sessionId from zustand stores
|
||||
// sessionId is per-tab (sessionStorage), nickname is shared (localStorage)
|
||||
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) {
|
||||
console.error(
|
||||
|
|
@ -458,15 +586,18 @@ export default function KvmIdRoute() {
|
|||
};
|
||||
|
||||
pc.onicecandidate = ({ candidate }) => {
|
||||
if (!candidate) return;
|
||||
if (candidate.candidate === "") return;
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (candidate.candidate === "") {
|
||||
return;
|
||||
}
|
||||
sendWebRTCSignal("new-ice-candidate", candidate);
|
||||
};
|
||||
|
||||
pc.onicegatheringstatechange = event => {
|
||||
const pc = event.currentTarget as RTCPeerConnection;
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
console.debug("ICE Gathering completed");
|
||||
setLoadingMessage("ICE Gathering completed");
|
||||
|
||||
if (isLegacySignalingEnabled.current) {
|
||||
|
|
@ -474,7 +605,6 @@ export default function KvmIdRoute() {
|
|||
legacyHTTPSignaling(pc);
|
||||
}
|
||||
} else if (pc.iceGatheringState === "gathering") {
|
||||
console.debug("ICE Gathering Started");
|
||||
setLoadingMessage("Gathering ICE candidates...");
|
||||
}
|
||||
};
|
||||
|
|
@ -505,10 +635,13 @@ export default function KvmIdRoute() {
|
|||
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
|
||||
};
|
||||
|
||||
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", {
|
||||
ordered: false,
|
||||
maxRetransmits: 0,
|
||||
});
|
||||
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel(
|
||||
"hidrpc-unreliable-nonordered",
|
||||
{
|
||||
ordered: false,
|
||||
maxRetransmits: 0,
|
||||
},
|
||||
);
|
||||
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
|
||||
rpcHidUnreliableNonOrderedChannel.onopen = () => {
|
||||
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
|
||||
|
|
@ -599,19 +732,24 @@ export default function KvmIdRoute() {
|
|||
}
|
||||
|
||||
// Fire and forget
|
||||
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
||||
bytesReceived: bytesReceivedDelta,
|
||||
bytesSent: bytesSentDelta,
|
||||
}).catch(() => {
|
||||
// we don't care about errors here, but we don't want unhandled promise rejections
|
||||
});
|
||||
api
|
||||
.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
||||
bytesReceived: bytesReceivedDelta,
|
||||
bytesSent: bytesSentDelta,
|
||||
})
|
||||
.catch(() => {
|
||||
// we don't care about errors here, but we don't want unhandled promise rejections
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
const { setNetworkState } = useNetworkStateStore();
|
||||
const { setHdmiState } = useVideoStore();
|
||||
const {
|
||||
keyboardLedState, setKeyboardLedState,
|
||||
keysDownState, setKeysDownState, setUsbState,
|
||||
keyboardLedState,
|
||||
setKeyboardLedState,
|
||||
keysDownState,
|
||||
setKeysDownState,
|
||||
setUsbState,
|
||||
} = useHidStore();
|
||||
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
|
||||
|
||||
|
|
@ -619,42 +757,56 @@ export default function KvmIdRoute() {
|
|||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||
if (resp.method === "otherSessionConnected") {
|
||||
navigateTo("/other-session");
|
||||
// Handle session-related events
|
||||
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") {
|
||||
const usbState = resp.params as unknown as USBStates;
|
||||
console.debug("Setting USB state", usbState);
|
||||
setUsbState(usbState);
|
||||
}
|
||||
|
||||
if (resp.method === "videoInputState") {
|
||||
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
|
||||
console.debug("Setting HDMI state", hdmiState);
|
||||
setHdmiState(hdmiState);
|
||||
}
|
||||
|
||||
if (resp.method === "networkState") {
|
||||
console.debug("Setting network state", resp.params);
|
||||
setNetworkState(resp.params as NetworkState);
|
||||
}
|
||||
|
||||
if (resp.method === "keyboardLedState") {
|
||||
const ledState = resp.params as KeyboardLedState;
|
||||
console.debug("Setting keyboard led state", ledState);
|
||||
setKeyboardLedState(ledState);
|
||||
}
|
||||
|
||||
if (resp.method === "keysDownState") {
|
||||
const downState = resp.params as KeysDownState;
|
||||
console.debug("Setting key down state:", downState);
|
||||
setKeysDownState(downState);
|
||||
}
|
||||
|
||||
if (resp.method === "otaState") {
|
||||
const otaState = resp.params as OtaState;
|
||||
console.debug("Setting OTA state", otaState);
|
||||
setOtaState(otaState);
|
||||
|
||||
if (otaState.updating === true) {
|
||||
|
|
@ -687,24 +839,44 @@ export default function KvmIdRoute() {
|
|||
|
||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||
|
||||
const {
|
||||
handleSessionResponse,
|
||||
handleRpcEvent,
|
||||
primaryControlRequest,
|
||||
handleApprovePrimaryRequest,
|
||||
handleDenyPrimaryRequest,
|
||||
closePrimaryControlRequest,
|
||||
newSessionRequest,
|
||||
handleApproveNewSession,
|
||||
handleDenyNewSession,
|
||||
closeNewSessionRequest,
|
||||
} = useSessionManagement(send);
|
||||
|
||||
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
||||
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
console.log("Requesting video state");
|
||||
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
|
||||
|
||||
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
||||
console.debug("Setting HDMI state", hdmiState);
|
||||
setHdmiState(hdmiState);
|
||||
});
|
||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||
}, [
|
||||
rpcDataChannel?.readyState,
|
||||
hasPermission,
|
||||
isLoadingPermissions,
|
||||
send,
|
||||
setHdmiState,
|
||||
]);
|
||||
|
||||
const [needLedState, setNeedLedState] = useState(true);
|
||||
|
||||
// request keyboard led state from the device
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
if (!needLedState) return;
|
||||
console.log("Requesting keyboard led state");
|
||||
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||
|
||||
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
|
|
@ -712,39 +884,54 @@ export default function KvmIdRoute() {
|
|||
return;
|
||||
} else {
|
||||
const ledState = resp.result as KeyboardLedState;
|
||||
console.debug("Keyboard led state: ", ledState);
|
||||
setKeyboardLedState(ledState);
|
||||
}
|
||||
setNeedLedState(false);
|
||||
});
|
||||
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
|
||||
}, [
|
||||
rpcDataChannel?.readyState,
|
||||
send,
|
||||
setKeyboardLedState,
|
||||
keyboardLedState,
|
||||
needLedState,
|
||||
hasPermission,
|
||||
isLoadingPermissions,
|
||||
]);
|
||||
|
||||
const [needKeyDownState, setNeedKeyDownState] = useState(true);
|
||||
|
||||
// request keyboard key down state from the device
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
if (!needKeyDownState) return;
|
||||
console.log("Requesting keys down state");
|
||||
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||
|
||||
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
// -32601 means the method is not supported
|
||||
if (resp.error.code === RpcMethodNotFound) {
|
||||
// if we don't support key down state, we know key press is also not available
|
||||
console.warn("Failed to get key down state, switching to old-school", resp.error);
|
||||
console.warn(
|
||||
"Failed to get key down state, switching to old-school",
|
||||
resp.error,
|
||||
);
|
||||
setHidRpcDisabled(true);
|
||||
} else {
|
||||
console.error("Failed to get key down state", resp.error);
|
||||
}
|
||||
} else {
|
||||
const downState = resp.result as KeysDownState;
|
||||
console.debug("Keyboard key down state", downState);
|
||||
setKeysDownState(downState);
|
||||
}
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -777,9 +964,11 @@ export default function KvmIdRoute() {
|
|||
|
||||
useEffect(() => {
|
||||
if (appVersion) return;
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
|
||||
getLocalVersion();
|
||||
}, [appVersion, getLocalVersion]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appVersion, rpcDataChannel?.readyState]);
|
||||
|
||||
const ConnectionStatusElement = useMemo(() => {
|
||||
const isOtherSession = location.pathname.includes("other-session");
|
||||
|
|
@ -787,7 +976,9 @@ export default function KvmIdRoute() {
|
|||
|
||||
// Rebooting takes priority over connection status
|
||||
if (rebootState?.isRebooting) {
|
||||
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
|
||||
return (
|
||||
<RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />
|
||||
);
|
||||
}
|
||||
|
||||
const hasConnectionFailed =
|
||||
|
|
@ -814,87 +1005,188 @@ export default function KvmIdRoute() {
|
|||
}
|
||||
|
||||
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 (
|
||||
<FeatureFlagProvider appVersion={appVersion}>
|
||||
{!outlet && otaState.updating && (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
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"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
<PermissionsProvider>
|
||||
<FeatureFlagProvider appVersion={appVersion}>
|
||||
{!outlet && otaState.updating && (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
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"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
</div>
|
||||
<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">
|
||||
{/* 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>
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="z-50"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") navigateTo("/");
|
||||
}}
|
||||
>
|
||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||
<Outlet context={{ setupPeerConnection }} />
|
||||
</Modal>
|
||||
</div>
|
||||
<div
|
||||
className="z-50"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") navigateTo("/");
|
||||
}}
|
||||
>
|
||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||
<Outlet context={{ setupPeerConnection }} />
|
||||
</Modal>
|
||||
|
||||
{kvmTerminal && (
|
||||
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
|
||||
)}
|
||||
<NicknameModal
|
||||
isOpen={showNicknameModal}
|
||||
onSubmit={async nickname => {
|
||||
setNickname(nickname);
|
||||
setShowNicknameModal(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
|
||||
{serialConsole && (
|
||||
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
||||
)}
|
||||
</FeatureFlagProvider>
|
||||
if (currentSessionId && send) {
|
||||
try {
|
||||
await sessionApi.updateNickname(send, currentSessionId, nickname);
|
||||
} 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);
|
||||
resolve(response);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 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) {
|
||||
if currentSession != nil {
|
||||
currentSession.reportHidRPCKeyboardLedState(state)
|
||||
// Check if keystrokes should be private
|
||||
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) {
|
||||
if currentSession != nil {
|
||||
currentSession.enqueueKeysDownState(state)
|
||||
// Check if keystrokes should be private
|
||||
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() {
|
||||
if currentSession != nil {
|
||||
currentSession.resetKeepAliveTime()
|
||||
// Reset keep-alive for primary session
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return gadget.GetKeyboardState()
|
||||
}
|
||||
|
|
@ -89,11 +168,7 @@ func rpcGetUSBState() (state string) {
|
|||
|
||||
func triggerUSBStateUpdate() {
|
||||
go func() {
|
||||
if currentSession == nil {
|
||||
usbLogger.Info().Msg("No active RPC session, skipping USB state update")
|
||||
return
|
||||
}
|
||||
writeJSONRPCEvent("usbState", usbState, currentSession)
|
||||
broadcastJSONRPCEvent("usbState", usbState)
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
|
|||
2
video.go
2
video.go
|
|
@ -20,7 +20,7 @@ const (
|
|||
|
||||
func triggerVideoStateUpdate() {
|
||||
go func() {
|
||||
writeJSONRPCEvent("videoInputState", lastVideoState, currentSession)
|
||||
broadcastJSONRPCEvent("videoInputState", lastVideoState)
|
||||
}()
|
||||
|
||||
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")
|
||||
|
|
|
|||
206
web.go
206
web.go
|
|
@ -34,10 +34,25 @@ import (
|
|||
var staticFiles embed.FS
|
||||
|
||||
type WebRTCSessionRequest struct {
|
||||
Sd string `json:"sd"`
|
||||
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
ICEServers []string `json:"iceServers,omitempty"`
|
||||
Sd string `json:"sd"`
|
||||
SessionId string `json:"sessionId,omitempty"`
|
||||
OidcGoogle string `json:"OidcGoogle,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 {
|
||||
|
|
@ -158,32 +173,16 @@ func setupRouter() *gin.Engine {
|
|||
protected := r.Group("/")
|
||||
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.POST("/cloud/register", handleCloudRegister)
|
||||
protected.GET("/cloud/state", handleCloudState)
|
||||
protected.GET("/device", handleDevice)
|
||||
protected.POST("/auth/logout", handleLogout)
|
||||
|
||||
protected.POST("/auth/password-local", handleCreatePassword)
|
||||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
||||
protected.POST("/storage/upload", handleUploadHttp)
|
||||
protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword)
|
||||
protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword)
|
||||
protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword)
|
||||
protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp)
|
||||
}
|
||||
|
||||
// Catch-all route for SPA
|
||||
|
|
@ -198,44 +197,6 @@ func setupRouter() *gin.Engine {
|
|||
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 (
|
||||
pingMessage = []byte("ping")
|
||||
pongMessage = []byte("pong")
|
||||
|
|
@ -244,7 +205,15 @@ var (
|
|||
func handleLocalWebRTCSignal(c *gin.Context) {
|
||||
// get the source from the request
|
||||
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().
|
||||
Str("component", "websocket").
|
||||
|
|
@ -276,7 +245,17 @@ func handleLocalWebRTCSignal(c *gin.Context) {
|
|||
// Now use conn for websocket operations
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
|
@ -380,6 +359,13 @@ func handleWebRTCSignalWsMessages(
|
|||
typ, msg, err := wsCon.Read(runCtx)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
|
|
@ -412,14 +398,17 @@ func handleWebRTCSignalWsMessages(
|
|||
continue
|
||||
}
|
||||
|
||||
l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message")
|
||||
|
||||
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
|
||||
err = json.Unmarshal(message.Data, &req)
|
||||
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
|
||||
}
|
||||
l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request")
|
||||
|
||||
if req.OidcGoogle != "" {
|
||||
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
|
||||
|
|
@ -427,7 +416,7 @@ func handleWebRTCSignalWsMessages(
|
|||
|
||||
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
||||
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 {
|
||||
l.Warn().Str("error", err.Error()).Msg("error starting new session")
|
||||
continue
|
||||
|
|
@ -449,14 +438,16 @@ func handleWebRTCSignalWsMessages(
|
|||
|
||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
|
||||
|
||||
if currentSession == nil {
|
||||
l.Warn().Msg("no current session, skipping incoming ICE candidate")
|
||||
// Find the session this ICE candidate belongs to using the connectionID
|
||||
session := sessionManager.GetSession(connectionID)
|
||||
if session == nil {
|
||||
l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session")
|
||||
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
|
||||
l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection")
|
||||
l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session")
|
||||
if err = session.peerConnection.AddICECandidate(candidate); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
config.LocalAuthToken = ""
|
||||
if err := SaveConfig(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
||||
return
|
||||
// Get session ID from cookie before clearing
|
||||
sessionID, _ := c.Cookie("sessionId")
|
||||
|
||||
// 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("sessionId", "", -1, "/", "", false, true)
|
||||
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) {
|
||||
c.JSON(status, gin.H{"error": message})
|
||||
c.Abort()
|
||||
|
|
@ -591,7 +639,7 @@ func RunWebServer() {
|
|||
|
||||
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
|
||||
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("", "")
|
||||
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"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
|
@ -19,15 +20,42 @@ import (
|
|||
"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 {
|
||||
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
|
||||
VideoTrack *webrtc.TrackLocalStaticSample
|
||||
ControlChannel *webrtc.DataChannel
|
||||
RPCChannel *webrtc.DataChannel
|
||||
HidChannel *webrtc.DataChannel
|
||||
shouldUmountVirtualMedia bool
|
||||
|
||||
rpcQueue chan webrtc.DataChannelMessage
|
||||
flushCandidates func() // Callback to flush buffered ICE candidates
|
||||
ws *websocket.Conn // WebSocket for critical signaling when RPC unavailable
|
||||
rpcQueue chan webrtc.DataChannelMessage
|
||||
|
||||
hidRPCAvailable bool
|
||||
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
|
||||
|
|
@ -39,32 +67,38 @@ type Session struct {
|
|||
keysDownStateQueue chan usbgadget.KeysDownState
|
||||
}
|
||||
|
||||
var (
|
||||
actionSessions int = 0
|
||||
activeSessionsMutex = &sync.Mutex{}
|
||||
)
|
||||
var actionSessions atomic.Int32
|
||||
|
||||
func incrActiveSessions() int {
|
||||
activeSessionsMutex.Lock()
|
||||
defer activeSessionsMutex.Unlock()
|
||||
|
||||
actionSessions++
|
||||
return actionSessions
|
||||
func incrActiveSessions() int32 {
|
||||
return actionSessions.Add(1)
|
||||
}
|
||||
|
||||
func decrActiveSessions() int {
|
||||
activeSessionsMutex.Lock()
|
||||
defer activeSessionsMutex.Unlock()
|
||||
|
||||
actionSessions--
|
||||
return actionSessions
|
||||
func getActiveSessions() int32 {
|
||||
return actionSessions.Load()
|
||||
}
|
||||
|
||||
func getActiveSessions() int {
|
||||
activeSessionsMutex.Lock()
|
||||
defer activeSessionsMutex.Unlock()
|
||||
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
|
||||
func (s *Session) CheckRPCRateLimit() bool {
|
||||
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() {
|
||||
|
|
@ -74,6 +108,25 @@ func (s *Session) resetKeepAliveTime() {
|
|||
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 {
|
||||
webrtc.DataChannelMessage
|
||||
channel string
|
||||
|
|
@ -83,6 +136,7 @@ type SessionConfig struct {
|
|||
ICEServers []string
|
||||
LocalIP string
|
||||
IsCloud bool
|
||||
UserAgent string // User agent for browser detection and nickname generation
|
||||
ws *websocket.Conn
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
|
@ -246,7 +300,11 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
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.initQueues()
|
||||
session.initKeysDownStateQueue()
|
||||
|
|
@ -283,16 +341,22 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
case "rpc":
|
||||
session.RPCChannel = d
|
||||
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
|
||||
})
|
||||
triggerOTAStateUpdate()
|
||||
triggerVideoStateUpdate()
|
||||
triggerUSBStateUpdate()
|
||||
case "terminal":
|
||||
handleTerminalChannel(d)
|
||||
handleTerminalChannel(d, session)
|
||||
case "serial":
|
||||
handleSerialChannel(d)
|
||||
handleSerialChannel(d, session)
|
||||
default:
|
||||
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
|
||||
go handleUploadChannel(d)
|
||||
|
|
@ -325,18 +389,116 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
}()
|
||||
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) {
|
||||
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
|
||||
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 {
|
||||
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) {
|
||||
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 !isConnected {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
if connectionState == webrtc.ICEConnectionStateClosed {
|
||||
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
||||
if session == currentSession {
|
||||
// Cancel any ongoing keyboard report multi when session closes
|
||||
cancelKeyboardMacro()
|
||||
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()
|
||||
}
|
||||
}
|
||||
scopedLogger.Info().
|
||||
Str("sessionID", session.ID).
|
||||
Msg("ICE Connection State is closed, cleaning up")
|
||||
cleanupSession("ice-closed")
|
||||
}
|
||||
})
|
||||
return session, nil
|
||||
|
|
|
|||
Loading…
Reference in New Issue