feat: multi-session support with role-based permissions

Implements concurrent WebRTC session management with granular permission control, enabling multiple users to connect simultaneously with different access levels.

Features:
- Session modes: Primary (full control), Observer (view-only), Queued, Pending
- Role-based permissions (31 permissions across video, input, settings, system)
- Session approval workflow with configurable access control
- Primary control transfer, request, and approval mechanisms
- Grace period reconnection (prevents interruption on network issues)
- Automatic session timeout and cleanup
- Nickname system with browser-based auto-generation
- Trust-based emergency promotion (deadlock prevention)
- Session blacklisting (prevents transfer abuse)

Technical Implementation:
- Centralized permission system (internal/session package)
- Broadcast throttling (100ms global, 50ms per-session) for DoS protection
- Defense-in-depth permission validation
- Pre-allocated event maps for hot-path performance
- Lock-free session iteration with snapshot pattern
- Comprehensive session management UI with real-time updates

New Files:
- session_manager.go (1628 lines) - Core session lifecycle
- internal/session/permissions.go (306 lines) - Permission rules
- session_permissions.go (77 lines) - Package integration
- datachannel_helpers.go (11 lines) - Permission denied handler
- errors.go (10 lines) - Error definitions
- 14 new UI components (session management, approval dialogs, overlays)

50 files changed, 5836 insertions(+), 442 deletions(-)
This commit is contained in:
Alex P 2025-10-08 17:45:37 +03:00
parent 317218a682
commit cd70efb83f
50 changed files with 5884 additions and 442 deletions

View File

@ -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,72 @@ 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")
}
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
}
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()
}
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
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
}

View File

@ -77,11 +77,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"`
@ -104,6 +114,7 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
SessionSettings *SessionSettings `json:"session_settings"`
}
func (c *Config) GetDisplayRotation() uint16 {
@ -132,12 +143,25 @@ var defaultConfig = &Config{
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
SessionSettings: &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10, // 10 seconds default
PrivateKeystrokes: false, // By default, share keystrokes with observers
},
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{

11
datachannel_helpers.go Normal file
View File

@ -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) {})
}

10
errors.go Normal file
View File

@ -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")
)

View File

@ -27,8 +27,14 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
if !session.HasPermission(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,18 +42,30 @@ 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) {
return
}
pointerReport, err := message.PointerReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get pointer report")
return
}
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
rpcErr = rpcAbsMouseReport(int16(pointerReport.X), int16(pointerReport.Y), pointerReport.Button)
case hidrpc.TypeMouseReport:
if !session.HasPermission(PermissionMouseInput) {
return
}
mouseReport, err := message.MouseReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get mouse report")

View File

@ -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 minimal permissions
PermissionVideoView: true,
},
}
// 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
}

11
internal/session/types.go Normal file
View File

@ -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"
)

View File

@ -354,7 +354,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
u.keyboardStateLock.Unlock()
if u.onKeysDownChange != nil {
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
(*u.onKeysDownChange)(state)
}
return state
}

View File

@ -85,7 +85,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil
}
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
func (u *UsbGadget) AbsMouseReport(x int16, y int16, buttons uint8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()

View File

@ -133,11 +133,12 @@ func runJiggler() {
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
logger.Debug().Msg("Jiggling mouse...")
//TODO: change to rel mouse
err := rpcAbsMouseReport(1, 1, 0)
// Use direct hardware calls for jiggler - bypass session permissions
err := gadget.AbsMouseReport(1, 1, 0)
if err != nil {
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
}
err = rpcAbsMouseReport(0, 0, 0)
err = gadget.AbsMouseReport(0, 0, 0)
if err != nil {
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
}

View File

@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strconv"
"sync"
"time"
@ -23,6 +24,14 @@ 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)
}
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
@ -47,6 +56,7 @@ type DisplayRotationSettings struct {
Rotation string `json:"rotation"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
@ -54,11 +64,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")
@ -96,7 +111,30 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
}
}
func broadcastJSONRPCEvent(event string, params any) {
sessionManager.ForEachSession(func(s *Session) {
writeJSONRPCEvent(event, params, s)
})
}
func onRPCMessage(message webrtc.DataChannelMessage, session *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 +162,206 @@ 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,
// Handle session-specific RPC methods first
var result any
var handlerErr error
switch request.Method {
case "approvePrimaryRequest":
if err := RequirePermission(session, PermissionSessionTransfer); err != nil {
handlerErr = err
} else if requesterID, ok := request.Params["requesterID"].(string); ok {
handlerErr = sessionManager.ApprovePrimaryRequest(session.ID, requesterID)
if handlerErr == nil {
result = map[string]interface{}{"status": "approved"}
}
} else {
handlerErr = errors.New("invalid requesterID parameter")
}
writeJSONRPCResponse(errorResponse, session)
return
case "denyPrimaryRequest":
if err := RequirePermission(session, PermissionSessionTransfer); err != nil {
handlerErr = err
} else if requesterID, ok := request.Params["requesterID"].(string); ok {
handlerErr = sessionManager.DenyPrimaryRequest(session.ID, requesterID)
if handlerErr == nil {
result = map[string]interface{}{"status": "denied"}
}
} else {
handlerErr = errors.New("invalid requesterID parameter")
}
case "approveNewSession":
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
targetSession.Mode = SessionModeObserver
sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "approved"}
} else {
handlerErr = errors.New("session not found or not pending")
}
} else {
handlerErr = errors.New("invalid sessionId parameter")
}
case "denyNewSession":
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session",
}, targetSession)
sessionManager.RemoveSession(sessionID)
result = map[string]interface{}{"status": "denied"}
} else {
handlerErr = errors.New("session not found or not pending")
}
} else {
handlerErr = errors.New("invalid sessionId parameter")
}
case "updateSessionNickname":
sessionID, _ := request.Params["sessionId"].(string)
nickname, _ := request.Params["nickname"].(string)
// Validate nickname to match frontend validation
if len(nickname) < 2 {
handlerErr = errors.New("nickname must be at least 2 characters")
} else if len(nickname) > 30 {
handlerErr = errors.New("nickname must be 30 characters or less")
} else if !isValidNickname(nickname) {
handlerErr = errors.New("nickname can only contain letters, numbers, spaces, and - _ . @")
} else if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
// Users can update their own nickname, or admins can update any
if targetSession.ID == session.ID || session.HasPermission(PermissionSessionManage) {
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()
result = map[string]interface{}{"status": "updated"}
} else {
handlerErr = errors.New("permission denied: can only update own nickname")
}
} else {
handlerErr = errors.New("session not found")
}
case "getSessions":
sessions := sessionManager.GetAllSessions()
result = sessions
case "getPermissions":
permissions := session.GetPermissions()
permMap := make(map[string]bool)
for perm, allowed := range permissions {
permMap[string(perm)] = allowed
}
result = GetPermissionsResponse{
Mode: string(session.Mode),
Permissions: permMap,
}
case "getSessionSettings":
if err := RequirePermission(session, PermissionSettingsRead); err != nil {
handlerErr = err
} else {
result = currentSessionSettings
}
case "setSessionSettings":
if err := RequirePermission(session, PermissionSessionManage); err != nil {
handlerErr = err
} else {
if settings, ok := request.Params["settings"].(map[string]interface{}); ok {
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
}
// Trigger nickname auto-generation for sessions when RequireNickname changes
if sessionManager != nil {
sessionManager.updateAllSessionNicknames()
}
// Save to persistent config
if err := SaveConfig(); err != nil {
handlerErr = errors.New("failed to save session settings")
}
result = currentSessionSettings
} else {
handlerErr = errors.New("invalid settings parameter")
}
}
case "generateNickname":
// Generate a nickname based on user agent (no permissions required)
userAgent := ""
if request.Params != nil {
if ua, ok := request.Params["userAgent"].(string); ok {
userAgent = ua
}
}
// Use browser as fallback if no user agent provided
if userAgent == "" {
userAgent = "Mozilla/5.0 (Unknown) Browser"
}
result = map[string]string{
"nickname": generateNicknameFromUserAgent(userAgent),
}
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
}
}
// 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 +377,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",
@ -1084,6 +1307,93 @@ 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
}
func (s *Session) rpcApprovePrimaryRequest(requesterID string) error {
if s == nil || s.ID == "" {
return errors.New("invalid session")
}
return sessionManager.ApprovePrimaryRequest(s.ID, requesterID)
}
func (s *Session) rpcDenyPrimaryRequest(requesterID string) error {
if s == nil || s.ID == "" {
return errors.New("invalid session")
}
return sessionManager.DenyPrimaryRequest(s.ID, requesterID)
}
var (
keyboardMacroCancel context.CancelFunc
keyboardMacroLock sync.Mutex
@ -1119,8 +1429,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)
@ -1128,8 +1439,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
@ -1267,4 +1578,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"}},
}

15
main.go
View File

@ -16,6 +16,18 @@ 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,
}
SaveConfig()
}
currentSessionSettings = config.SessionSettings
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
@ -91,7 +103,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

View File

@ -48,12 +48,21 @@ 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")
}
}
})
},
})
nativeInstance.Start()

View File

@ -62,12 +62,7 @@ func initNetwork() error {
},
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
if currentSession == nil {
return
}
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
broadcastJSONRPCEvent("networkState", networkState.RpcGetNetworkState())
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig

6
ota.go
View File

@ -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)
}()
}

View File

@ -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() {

1633
session_manager.go Normal file

File diff suppressed because it is too large Load Diff

77
session_permissions.go Normal file
View File

@ -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
}

View File

@ -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

113
ui/src/api/sessionApi.ts Normal file
View File

@ -0,0 +1,113 @@
import { SessionInfo } from "@/stores/sessionStore";
export const sessionApi = {
getSessions: async (sendFn: Function): Promise<SessionInfo[]> => {
return new Promise((resolve, reject) => {
sendFn("getSessions", {}, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve(response.result || []);
}
});
});
},
getSessionInfo: async (sendFn: Function, sessionId: string): Promise<SessionInfo> => {
return new Promise((resolve, reject) => {
sendFn("getSessionInfo", { sessionId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve(response.result);
}
});
});
},
requestPrimary: async (sendFn: Function, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => {
return new Promise((resolve, reject) => {
sendFn("requestPrimary", { sessionId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve(response.result);
}
});
});
},
releasePrimary: async (sendFn: Function, sessionId: string): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("releasePrimary", { sessionId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
},
transferPrimary: async (
sendFn: Function,
fromId: string,
toId: string
): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("transferPrimary", { fromId, toId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
},
updateNickname: async (
sendFn: Function,
sessionId: string,
nickname: string
): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("updateSessionNickname", { sessionId, nickname }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
},
approveNewSession: async (
sendFn: Function,
sessionId: string
): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("approveNewSession", { sessionId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
},
denyNewSession: async (
sendFn: Function,
sessionId: string
): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("denyNewSession", { sessionId }, (response: any) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
}
};

View File

@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { Button } from "./Button";
import { DEVICE_API, CLOUD_API } from "@/ui.config";
import { isOnDevice } from "@/main";
import { useUserStore } from "@/hooks/stores";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import api from "@/api";
interface AccessDeniedOverlayProps {
show: boolean;
message?: string;
onRetry?: () => void;
}
export default function AccessDeniedOverlay({
show,
message = "Your session access was denied",
onRetry
}: AccessDeniedOverlayProps) {
const navigate = useNavigate();
const setUser = useUserStore(state => state.setUser);
const { clearSession } = useSessionStore();
const { clearNickname } = useSharedSessionStore();
const [countdown, setCountdown] = useState(10);
const handleLogout = 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("/");
};
useEffect(() => {
if (!show) return;
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
// Auto-redirect with proper logout
handleLogout();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [show]);
if (!show) 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>
<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">
{onRetry && (
<Button
onClick={onRetry}
theme="primary"
size="MD"
text="Try Again"
fullWidth
/>
)}
<Button
onClick={() => {
handleLogout();
}}
theme="light"
size="MD"
text="Back to Login"
fullWidth
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 {
@ -18,7 +18,11 @@ 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 { useRTCStore } from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions";
export default function Actionbar({
requestFullscreen,
@ -33,6 +37,37 @@ 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);
rpcDataChannel.removeEventListener("message", handler);
}
} catch (error) {
// Ignore parse errors for non-JSON messages
}
};
rpcDataChannel.addEventListener("message", handler);
rpcDataChannel.send(message);
// Clean up after timeout
setTimeout(() => {
rpcDataChannel.removeEventListener("message", handler);
}, 5000);
}
}, [rpcDataChannel?.readyState]);
// 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 +79,6 @@ export default function Actionbar({
if (!open) {
setTimeout(() => {
setDisableVideoFocusTrap(false);
console.debug("Popover is closing. Returning focus trap to video");
}, 0);
}
}
@ -69,179 +103,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 +352,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" />

View File

@ -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();

View File

@ -6,21 +6,25 @@ import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions";
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;
}

View File

@ -0,0 +1,262 @@
import { useState, useEffect, useRef } from "react";
import { Dialog, DialogPanel, DialogBackdrop } from "@headlessui/react";
import { UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Button } from "./Button";
import { useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore } from "@/hooks/stores";
import { generateNickname } from "@/utils/nicknameGenerator";
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: any) {
setError(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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,139 @@
import { useSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi";
import { Button } from "@/components/Button";
import {
LockClosedIcon,
LockOpenIcon,
ClockIcon
} from "@heroicons/react/16/solid";
import clsx from "clsx";
import { usePermissions, Permission } from "@/hooks/usePermissions";
interface SessionControlPanelProps {
sendFn: Function;
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: any) {
setSessionError(error.message);
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: any) {
setSessionError(error.message);
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>
);
}

View File

@ -0,0 +1,149 @@
import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { formatters } from "@/utils";
import { usePermissions, Permission } from "@/hooks/usePermissions";
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>
);
}

View File

@ -0,0 +1,243 @@
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>;
onClose: () => void;
}
export default function UnifiedSessionRequestDialog({
request,
onApprove,
onDeny,
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);
}
}, [request?.id, request?.type]); // Only depend on stable properties
// 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 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>
</div>
</div>
</div>
);
}

View File

@ -14,6 +14,8 @@ import {
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import useMouse from "@/hooks/useMouse";
import {
@ -35,6 +37,8 @@ export default function WebRTCVideo() {
// Store hooks
const settings = useSettingsStore();
const { currentMode } = useSessionStore();
const { hasPermission } = usePermissions();
const { handleKeyPress, resetKeyboardState } = useKeyboard();
const {
getRelMouseMoveHandler,
@ -214,29 +218,47 @@ export default function WebRTCVideo() {
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);
};
}, [currentMode, getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight]);
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);
};
}, [currentMode, getRelMouseMoveHandler]);
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);
};
}, [currentMode, getMouseWheelHandler]);
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 +274,9 @@ export default function WebRTCVideo() {
// 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 +284,22 @@ export default function WebRTCVideo() {
// 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],
[currentMode, handleKeyPress, isKeyboardLockActive],
);
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 +308,9 @@ export default function WebRTCVideo() {
return;
}
console.debug(`Key up: ${hidKey}`);
handleKeyPress(hidKey, false);
},
[handleKeyPress],
[currentMode, handleKeyPress],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -297,7 +321,6 @@ export default function WebRTCVideo() {
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current.paused) {
console.debug("Force playing video");
videoElm.current.play();
}
}
@ -556,7 +579,7 @@ export default function WebRTCVideo() {
)}
</div>
</div>
<VirtualKeyboard />
{hasPermission(Permission.KEYBOARD_INPUT) && <VirtualKeyboard />}
</div>
</div>
</div>

View File

@ -0,0 +1,207 @@
import { useState, useEffect } from "react";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import {
UserGroupIcon,
ArrowPathIcon,
PencilIcon,
} from "@heroicons/react/20/solid";
import clsx from "clsx";
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 = (method: string, params: any, callback?: (response: any) => void) => {
send(method, params, (response) => {
if (callback) callback(response);
});
};
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));
}
}, []);
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: any) => {
if (response.error) {
console.error("Failed to approve session:", response.error);
} else {
handleRefresh();
}
});
}}
onDeny={(sessionId) => {
sendRpc("denyNewSession", { sessionId }, (response: any) => {
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>
);
}

View File

@ -329,6 +329,12 @@ export interface SettingsState {
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
requireSessionNickname: boolean;
setRequireSessionNickname: (required: boolean) => void;
requireSessionApproval: boolean;
setRequireSessionApproval: (required: boolean) => void;
displayRotation: string;
setDisplayRotation: (rotation: string) => void;
@ -369,6 +375,12 @@ 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 }),
displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),

View File

@ -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 };
}

View File

@ -0,0 +1,164 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
// Permission types matching backend
export enum Permission {
// Video/Display permissions
VIDEO_VIEW = "video.view",
// Input permissions
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
// Session management permissions
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 permissions
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
// Extension permissions
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
// Settings permissions
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
// System permissions
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
// Power/USB control permissions
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
// Terminal/Serial permissions
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}
interface PermissionsResponse {
mode: string;
permissions: Record<string, boolean>;
}
export function usePermissions() {
const { currentMode } = useSessionStore();
const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
// Function to poll permissions
const pollPermissions = useCallback((send: any) => {
if (!send) return;
setIsLoading(true);
send("getPermissions", {}, (response: any) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
// Handle connectionModeChanged events that require WebRTC reconnection
const handleRpcRequest = useCallback((request: any) => {
if (request.method === "connectionModeChanged") {
console.info("Connection mode changed, WebRTC reconnection required", request.params);
// For session promotion that requires reconnection, refresh the page
// This ensures WebRTC connection is re-established with proper mode
if (request.params?.action === "reconnect_required" && request.params?.reason === "session_promotion") {
console.info("Session promoted, refreshing page to re-establish WebRTC connection");
// Small delay to ensure all state updates are processed
setTimeout(() => {
window.location.reload();
}, 500);
}
}
}, []);
const { send } = useJsonRpc(handleRpcRequest);
useEffect(() => {
pollPermissions(send);
}, [send, currentMode, pollPermissions]);
// Monitor permission changes and re-initialize HID when gaining control
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
// If we just gained control permissions, re-initialize HID
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
// Reset protocol version to force re-handshake
setRpcHidProtocolVersion(null);
// Import handshake functionality dynamically
import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
// Send handshake after a small delay
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;
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
const hasPermission = (permission: Permission): boolean => {
return permissions[permission] === true;
};
const hasAnyPermission = (...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
};
const hasAllPermissions = (...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
};
// Session mode helpers
const isPrimary = () => currentMode === "primary";
const isObserver = () => currentMode === "observer";
const isPending = () => currentMode === "pending";
return {
permissions,
isLoading,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isPrimary,
isObserver,
isPending,
};
}

View File

@ -0,0 +1,153 @@
import { useEffect, useRef } from "react";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { sessionApi } from "@/api/sessionApi";
import { notify } from "@/notifications";
interface SessionEventData {
sessions: any[];
yourMode: string;
}
interface ModeChangedData {
mode: string;
}
export function useSessionEvents(sendFn: Function | null) {
const {
currentMode,
setSessions,
updateSessionMode,
setSessionError
} = useSessionStore();
const sendFnRef = useRef(sendFn);
sendFnRef.current = sendFn;
// Handle session-related RPC events
const handleSessionEvent = (method: string, params: any) => {
switch (method) {
case "sessionsUpdated":
handleSessionsUpdated(params as SessionEventData);
break;
case "modeChanged":
handleModeChanged(params as ModeChangedData);
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 any);
}
};
// 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 any);
// Clear requesting state when mode changes from queued
if (previousMode === "queued" && data.mode !== "queued") {
const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false);
}
// 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 handleHidReadyForPrimary = () => {
// Backend signals that HID system is ready for primary session re-initialization
const { rpcHidChannel } = useRTCStore.getState();
if (rpcHidChannel?.readyState === "open") {
// Trigger HID re-handshake
rpcHidChannel.dispatchEvent(new Event("open"));
}
};
const handleOtherSessionConnected = () => {
// Another session is trying to connect
notify.warning("Another session is connecting", {
duration: 5000
});
};
// Fetch initial sessions when component mounts
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]);
// Set up periodic session refresh
useEffect(() => {
if (!sendFnRef.current) return;
const intervalId = setInterval(async () => {
if (!sendFnRef.current) return;
try {
const sessions = await sessionApi.getSessions(sendFnRef.current);
setSessions(sessions);
} catch (error) {
// Silently fail on refresh errors
}
}, 30000); // Refresh every 30 seconds
return () => clearInterval(intervalId);
}, [setSessions]);
return {
handleSessionEvent
};
}

View File

@ -0,0 +1,177 @@
import { useEffect, useCallback, useState } from "react";
import { useSessionStore } from "@/stores/sessionStore";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useSettingsStore } from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions";
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: Function | null) {
const {
setCurrentSession,
clearSession
} = useSessionStore();
const { hasPermission } = usePermissions();
const { requireSessionApproval } = useSettingsStore();
const { handleSessionEvent } = useSessionEvents(sendFn);
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
// Handle session info from WebRTC answer
const handleSessionResponse = useCallback((response: SessionResponse) => {
if (response.sessionId && response.mode) {
setCurrentSession(response.sessionId, response.mode as any);
}
}, [setCurrentSession]);
// Handle approval of primary control request
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: any) => {
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]);
// Handle denial of primary control request
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: any) => {
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]);
// Handle approval of new session
const handleApproveNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("approveNewSession", { sessionId }, (response: any) => {
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]);
// Handle denial of new session
const handleDenyNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("denyNewSession", { sessionId }, (response: any) => {
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]);
// Handle RPC events
const handleRpcEvent = useCallback((method: string, params: any) => {
// Pass session events to the session event handler
if (method === "sessionsUpdated" ||
method === "modeChanged" ||
method === "otherSessionConnected") {
handleSessionEvent(method, params);
}
// Handle new session approval request (only if approval is required and user has permission)
if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params);
}
// Handle primary control request
if (method === "primaryControlRequested") {
setPrimaryControlRequest(params);
}
// Handle approval/denial responses
if (method === "primaryControlApproved") {
// Clear requesting state in store
const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false);
}
if (method === "primaryControlDenied") {
// Clear requesting state and show error
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
setRequestingPrimary(false);
setSessionError("Your primary control request was denied");
}
// Handle session access denial (when your new session is denied)
if (method === "sessionAccessDenied") {
const { clearSession, setSessionError } = useSessionStore.getState();
setSessionError(params.message || "Session access was denied by the primary session");
// Clear session data as we're being disconnected
setTimeout(() => {
clearSession();
}, 3000); // Give user time to see the error
}
}, [handleSessionEvent]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearSession();
};
}, [clearSession]);
return {
handleSessionResponse,
handleRpcEvent,
primaryControlRequest,
handleApprovePrimaryRequest,
handleDenyPrimaryRequest,
closePrimaryControlRequest: () => setPrimaryControlRequest(null),
newSessionRequest,
handleApproveNewSession,
handleDenyNewSession,
closeNewSessionRequest: () => setNewSessionRequest(null)
};
}

View File

@ -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 />,
},
],
},
],

View File

@ -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);

View File

@ -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"

View File

@ -6,6 +6,7 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
@ -15,6 +16,7 @@ export default function SettingsHardwareRoute() {
const { send } = useJsonRpc();
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const { hasPermission, isLoading, permissions } = usePermissions();
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
@ -58,17 +60,39 @@ export default function SettingsHardwareRoute() {
});
};
// Check permissions before fetching settings data
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]);
// 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">

View File

@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions";
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";
import {
UserGroupIcon,
} from "@heroicons/react/16/solid";
export default function SessionsSettings() {
const { send } = useJsonRpc();
const { hasPermission } = usePermissions();
const canModifySettings = hasPermission(Permission.SETTINGS_WRITE);
const {
requireSessionNickname,
setRequireSessionNickname,
requireSessionApproval,
setRequireSessionApproval
} = useSettingsStore();
const [reconnectGrace, setReconnectGrace] = useState(10);
const [primaryTimeout, setPrimaryTimeout] = useState(300);
const [privateKeystrokes, setPrivateKeystrokes] = useState(false);
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
};
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);
}
}
});
}, [send, setRequireSessionApproval, setRequireSessionNickname]);
const updateSessionSettings = (updates: Partial<{
requireApproval: boolean;
requireNickname: boolean;
reconnectGrace: number;
primaryTimeout: number;
privateKeystrokes: boolean;
}>) => {
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,
...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="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="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>
);
}

View File

@ -12,19 +12,33 @@ import {
LuPalette,
LuCommand,
LuNetwork,
LuUsers,
} from "react-icons/lu";
import { useResizeObserver } from "usehooks-ts";
import { useNavigate } from "react-router";
import { cx } from "@/cva.config";
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, Permission } from "@/hooks/usePermissions";
/* 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("/devices/local", { 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"

View File

@ -18,6 +18,7 @@ import useWebSocket from "react-use-websocket";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { cx } from "@/cva.config";
import {
KeyboardLedState,
@ -29,12 +30,17 @@ 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'));
@ -50,6 +56,9 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
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;
@ -122,7 +131,7 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap } = useUiStore();
const [ queryParams, setQueryParams ] = useSearchParams();
const {
@ -141,14 +150,20 @@ 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 { hasPermission } = usePermissions();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
setConnectionFailed(true);
if (peerConnection) {
@ -186,7 +201,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(
@ -204,7 +218,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) {
@ -218,10 +231,6 @@ export default function KvmIdRoute() {
cleanupAndStopReconnecting();
clearInterval(checkInterval);
} else {
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
});
}
}, 1000);
},
@ -244,18 +253,15 @@ export default function KvmIdRoute() {
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.debug("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.debug("[Websocket] shouldReconnect", event);
shouldReconnect(_event) {
// TODO: Why true?
return true;
},
onClose(event) {
console.debug("[Websocket] onClose", event);
onClose(_event) {
// We don't want to close everything down, we wait for the reconnect to stop instead
},
@ -264,7 +270,6 @@ export default function KvmIdRoute() {
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.debug("[Websocket] onOpen");
},
onMessage: message => {
@ -285,27 +290,49 @@ 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 &&
@ -319,14 +346,41 @@ 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),
@ -335,9 +389,11 @@ 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);
});
}
},
},
@ -350,9 +406,16 @@ export default function KvmIdRoute() {
(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(
@ -363,12 +426,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 }),
});
@ -381,7 +444,6 @@ export default function KvmIdRoute() {
return;
}
console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
@ -392,13 +454,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
@ -408,7 +468,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}`);
@ -420,13 +479,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();
@ -434,9 +491,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(
@ -450,15 +517,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) {
@ -466,7 +536,6 @@ export default function KvmIdRoute() {
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
@ -480,6 +549,44 @@ export default function KvmIdRoute() {
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel);
// Fetch global session settings
const fetchSettings = () => {
// Only fetch settings if user has permission to read settings
if (!hasPermission(Permission.SETTINGS_READ)) {
return;
}
const id = Math.random().toString(36).substring(2);
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id });
const handler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data);
if (response.id === id) {
rpcDataChannel.removeEventListener("message", handler);
if (response.result) {
setGlobalSessionSettings(response.result);
// Also update the settings store for approval handling
setRequireSessionApproval(response.result.requireApproval);
setRequireSessionNickname(response.result.requireNickname);
}
}
} catch (error) {
// Ignore parse errors
}
};
rpcDataChannel.addEventListener("message", handler);
rpcDataChannel.send(message);
// Clean up after timeout
setTimeout(() => {
rpcDataChannel.removeEventListener("message", handler);
}, 5000);
};
fetchSettings();
};
const rpcHidChannel = pc.createDataChannel("hidrpc");
@ -609,42 +716,54 @@ 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 === "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);
}
// Keep legacy behavior for otherSessionConnected
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) {
@ -670,13 +789,24 @@ export default function KvmIdRoute() {
const { send } = useJsonRpc(onJsonRpcRequest);
const {
handleSessionResponse,
handleRpcEvent,
primaryControlRequest,
handleApprovePrimaryRequest,
handleDenyPrimaryRequest,
closePrimaryControlRequest,
newSessionRequest,
handleApproveNewSession,
handleDenyNewSession,
closeNewSessionRequest
} = useSessionManagement(send);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state");
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]);
@ -687,7 +817,6 @@ export default function KvmIdRoute() {
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return;
console.log("Requesting keyboard led state");
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
@ -695,7 +824,6 @@ export default function KvmIdRoute() {
return;
} else {
const ledState = resp.result as KeyboardLedState;
console.debug("Keyboard led state: ", ledState);
setKeyboardLedState(ledState);
}
setNeedLedState(false);
@ -708,7 +836,6 @@ export default function KvmIdRoute() {
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return;
console.log("Requesting keys down state");
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
@ -722,7 +849,6 @@ export default function KvmIdRoute() {
}
} else {
const downState = resp.result as KeysDownState;
console.debug("Keyboard key down state", downState);
setKeysDownState(downState);
}
setNeedKeyDownState(false);
@ -840,16 +966,29 @@ export default function KvmIdRoute() {
kvmName={deviceName ?? "JetKVM Device"}
/>
<div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
{/* Only show video feed if nickname is set (when required) and not pending approval */}
{(!showNicknameModal && currentMode !== "pending") ? (
<>
<WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
</>
) : (
<div className="flex-1 bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-center">
{showNicknameModal && <p>Please set your nickname to continue</p>}
{currentMode === "pending" && <p>Waiting for session approval...</p>}
</div>
</div>
</div>
)}
<SidebarContainer sidebarView={sidebarView} />
</div>
</div>
@ -870,6 +1009,27 @@ export default function KvmIdRoute() {
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ setupPeerConnection }} />
</Modal>
<NicknameModal
isOpen={showNicknameModal}
onSubmit={async (nickname) => {
setNickname(nickname);
setShowNicknameModal(false);
setDisableVideoFocusTrap(false);
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 && (
@ -879,6 +1039,60 @@ export default function KvmIdRoute() {
{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
}
onClose={
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
/>
)}
<AccessDeniedOverlay
show={accessDenied}
message="Your session access was denied by the primary session"
onRetry={() => {
setAccessDenied(false);
// Attempt to reconnect
window.location.reload();
}}
/>
<PendingApprovalOverlay
show={currentMode === "pending"}
/>
</FeatureFlagProvider>
);
}

View File

@ -0,0 +1,160 @@
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;
// 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;
// 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,
// 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
});
},
// 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),
}
)
);

View File

@ -0,0 +1,33 @@
// Nickname generation using backend API for consistency
// Main function that uses backend generation
export async function generateNickname(sendFn?: Function): 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: any) => {
if (response && !response.error && response.result?.nickname) {
resolve(response.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
View File

@ -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 int16, y int16, 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 int16, y int16, 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)
}()
}

View File

@ -8,7 +8,7 @@ var lastVideoState native.VideoState
func triggerVideoStateUpdate() {
go func() {
writeJSONRPCEvent("videoInputState", lastVideoState, currentSession)
broadcastJSONRPCEvent("videoInputState", lastVideoState)
}()
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")

172
web.go
View File

@ -35,9 +35,21 @@ var staticFiles embed.FS
type WebRTCSessionRequest struct {
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
}
type SetPasswordRequest struct {
@ -158,32 +170,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 +194,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 +202,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 +242,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
@ -412,14 +388,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 +406,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 +428,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 +462,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 +480,10 @@ 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
}
// Clear the auth cookie
// Only 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) // Clear session ID cookie too
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
}
@ -519,6 +505,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 +609,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")
}
}

View File

@ -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")
}
}

229
webrtc.go
View File

@ -19,13 +19,39 @@ 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
peerConnection *webrtc.PeerConnection
VideoTrack *webrtc.TrackLocalStaticSample
ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
shouldUmountVirtualMedia bool
flushCandidates func() // Callback to flush buffered ICE candidates
rpcQueue chan webrtc.DataChannelMessage
@ -39,6 +65,30 @@ type Session struct {
keysDownStateQueue chan usbgadget.KeysDownState
}
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
func (s *Session) CheckRPCRateLimit() bool {
const (
maxRPCPerSecond = 20
rateLimitWindow = time.Second
)
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() {
s.keepAliveJitterLock.Lock()
defer s.keepAliveJitterLock.Unlock()
@ -55,6 +105,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
}
@ -106,7 +157,14 @@ func (s *Session) initQueues() {
func (s *Session) handleQueues(index int) {
for msg := range s.hidQueue[index] {
onHidMessage(msg, s)
// Get current session from manager to ensure we have the latest state
currentSession := sessionManager.GetSession(s.ID)
if currentSession != nil {
onHidMessage(msg, currentSession)
} else {
// Session was removed, use original to avoid nil panic
onHidMessage(msg, s)
}
}
}
@ -218,7 +276,10 @@ func newSession(config SessionConfig) (*Session, error) {
return nil, err
}
session := &Session{peerConnection: peerConnection}
session := &Session{
peerConnection: peerConnection,
Browser: extractBrowserFromUserAgent(config.UserAgent),
}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues()
session.initKeysDownStateQueue()
@ -226,7 +287,16 @@ func newSession(config SessionConfig) (*Session, error) {
go func() {
for msg := range session.rpcQueue {
// TODO: only use goroutine if the task is asynchronous
go onRPCMessage(msg, session)
go func(m webrtc.DataChannelMessage) {
// Get current session from manager to ensure we have the latest state
currentSession := sessionManager.GetSession(session.ID)
if currentSession != nil {
onRPCMessage(m, currentSession)
} else {
// Session was removed, use original to avoid nil panic
onRPCMessage(m, session)
}
}(msg)
}
}()
@ -262,9 +332,9 @@ func newSession(config SessionConfig) (*Session, error) {
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)
@ -297,9 +367,23 @@ 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 {
candidateBufferMutex.Lock()
if !answerSent {
// Buffer the candidate until answer is sent
candidateBuffer = append(candidateBuffer, candidate.ToJSON())
candidateBufferMutex.Unlock()
return
}
candidateBufferMutex.Unlock()
err := wsjson.Write(context.Background(), 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")
@ -307,8 +391,88 @@ func newSession(config SessionConfig) (*Session, error) {
}
})
// Store the callback to flush buffered candidates
session.flushCandidates = func() {
candidateBufferMutex.Lock()
answerSent = true
// Send all buffered candidates
for _, candidate := range candidateBuffer {
err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate})
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
actionSessions--
onActiveSessionsChanged()
if actionSessions == 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
@ -319,46 +483,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
actionSessions--
onActiveSessionsChanged()
if actionSessions == 0 {
onLastSessionDisconnected()
}
}
scopedLogger.Info().
Str("sessionID", session.ID).
Msg("ICE Connection State is closed, cleaning up")
cleanupSession("ice-closed")
}
})
return session, nil