mirror of https://github.com/jetkvm/kvm.git
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:
parent
317218a682
commit
cd70efb83f
94
cloud.go
94
cloud.go
|
|
@ -197,6 +197,20 @@ func wsResetMetrics(established bool, sourceType string, source string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCloudRegister(c *gin.Context) {
|
func handleCloudRegister(c *gin.Context) {
|
||||||
|
sessionID, _ := c.Cookie("sessionId")
|
||||||
|
authToken, _ := c.Cookie("authToken")
|
||||||
|
|
||||||
|
if sessionID != "" && authToken != "" && authToken == config.LocalAuthToken {
|
||||||
|
session := sessionManager.GetSession(sessionID)
|
||||||
|
if session != nil && !session.HasPermission(PermissionSettingsWrite) {
|
||||||
|
c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if sessionID != "" {
|
||||||
|
c.JSON(401, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req CloudRegisterRequest
|
var req CloudRegisterRequest
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -426,8 +440,15 @@ func handleSessionRequest(
|
||||||
req WebRTCSessionRequest,
|
req WebRTCSessionRequest,
|
||||||
isCloudConnection bool,
|
isCloudConnection bool,
|
||||||
source string,
|
source string,
|
||||||
|
connectionID string,
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) (returnErr error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
websocketLogger.Error().Interface("panic", r).Msg("PANIC in handleSessionRequest")
|
||||||
|
returnErr = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
var sourceType string
|
var sourceType string
|
||||||
if isCloudConnection {
|
if isCloudConnection {
|
||||||
sourceType = "cloud"
|
sourceType = "cloud"
|
||||||
|
|
@ -453,6 +474,7 @@ func handleSessionRequest(
|
||||||
IsCloud: isCloudConnection,
|
IsCloud: isCloudConnection,
|
||||||
LocalIP: req.IP,
|
LocalIP: req.IP,
|
||||||
ICEServers: req.ICEServers,
|
ICEServers: req.ICEServers,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
Logger: scopedLogger,
|
Logger: scopedLogger,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -462,26 +484,72 @@ func handleSessionRequest(
|
||||||
|
|
||||||
sd, err := session.ExchangeOffer(req.Sd)
|
sd, err := session.ExchangeOffer(req.Sd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to exchange offer")
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if currentSession != nil {
|
session.Source = source
|
||||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
|
||||||
peerConn := currentSession.peerConnection
|
if isCloudConnection && req.OidcGoogle != "" {
|
||||||
go func() {
|
session.Identity = config.GoogleIdentity
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
_ = peerConn.Close()
|
// Use client-provided sessionId for reconnection, otherwise generate new one
|
||||||
}()
|
// This enables multi-tab support while preserving reconnection on refresh
|
||||||
|
if req.SessionId != "" {
|
||||||
|
session.ID = req.SessionId
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID")
|
||||||
|
} else {
|
||||||
|
session.ID = connectionID
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.ID = connectionID
|
||||||
|
scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established")
|
||||||
}
|
}
|
||||||
|
|
||||||
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
if sessionManager == nil {
|
||||||
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
scopedLogger.Error().Msg("sessionManager is nil")
|
||||||
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
|
||||||
|
return fmt.Errorf("session manager not initialized")
|
||||||
|
}
|
||||||
|
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
|
if session.HasPermission(PermissionPaste) {
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
}
|
||||||
|
|
||||||
currentSession = session
|
requireNickname := false
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
requireApproval := false
|
||||||
|
if currentSessionSettings != nil {
|
||||||
|
requireNickname = currentSessionSettings.RequireNickname
|
||||||
|
requireApproval = currentSessionSettings.RequireApproval
|
||||||
|
}
|
||||||
|
|
||||||
|
err = wsjson.Write(context.Background(), c, gin.H{
|
||||||
|
"type": "answer",
|
||||||
|
"data": sd,
|
||||||
|
"sessionId": session.ID,
|
||||||
|
"mode": session.Mode,
|
||||||
|
"nickname": session.Nickname,
|
||||||
|
"requireNickname": requireNickname,
|
||||||
|
"requireApproval": requireApproval,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.flushCandidates != nil {
|
||||||
|
session.flushCandidates()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
config.go
24
config.go
|
|
@ -77,11 +77,21 @@ func (m *KeyboardMacro) Validate() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MultiSessionConfig defines settings for multi-session support
|
||||||
|
type MultiSessionConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MaxSessions int `json:"max_sessions"`
|
||||||
|
PrimaryTimeout int `json:"primary_timeout_seconds"`
|
||||||
|
AllowCloudOverride bool `json:"allow_cloud_override"`
|
||||||
|
RequireAuthTransfer bool `json:"require_auth_transfer"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CloudURL string `json:"cloud_url"`
|
CloudURL string `json:"cloud_url"`
|
||||||
CloudAppURL string `json:"cloud_app_url"`
|
CloudAppURL string `json:"cloud_app_url"`
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudToken string `json:"cloud_token"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
|
MultiSession *MultiSessionConfig `json:"multi_session"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
|
|
@ -104,6 +114,7 @@ type Config struct {
|
||||||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||||
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
||||||
DefaultLogLevel string `json:"default_log_level"`
|
DefaultLogLevel string `json:"default_log_level"`
|
||||||
|
SessionSettings *SessionSettings `json:"session_settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetDisplayRotation() uint16 {
|
func (c *Config) GetDisplayRotation() uint16 {
|
||||||
|
|
@ -132,12 +143,25 @@ var defaultConfig = &Config{
|
||||||
CloudAppURL: "https://app.jetkvm.com",
|
CloudAppURL: "https://app.jetkvm.com",
|
||||||
AutoUpdateEnabled: true, // Set a default value
|
AutoUpdateEnabled: true, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
|
MultiSession: &MultiSessionConfig{
|
||||||
|
Enabled: true, // Enable by default for new features
|
||||||
|
MaxSessions: 10, // Reasonable default
|
||||||
|
PrimaryTimeout: 300, // 5 minutes
|
||||||
|
AllowCloudOverride: true, // Cloud sessions can take control
|
||||||
|
RequireAuthTransfer: false, // Don't require auth by default
|
||||||
|
},
|
||||||
KeyboardMacros: []KeyboardMacro{},
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
DisplayRotation: "270",
|
DisplayRotation: "270",
|
||||||
KeyboardLayout: "en-US",
|
KeyboardLayout: "en-US",
|
||||||
DisplayMaxBrightness: 64,
|
DisplayMaxBrightness: 64,
|
||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
|
SessionSettings: &SessionSettings{
|
||||||
|
RequireApproval: false,
|
||||||
|
RequireNickname: false,
|
||||||
|
ReconnectGrace: 10, // 10 seconds default
|
||||||
|
PrivateKeystrokes: false, // By default, share keystrokes with observers
|
||||||
|
},
|
||||||
JigglerEnabled: false,
|
JigglerEnabled: false,
|
||||||
// This is the "Standard" jiggler option in the UI
|
// This is the "Standard" jiggler option in the UI
|
||||||
JigglerConfig: &JigglerConfig{
|
JigglerConfig: &JigglerConfig{
|
||||||
|
|
|
||||||
|
|
@ -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) {})
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
20
hidrpc.go
20
hidrpc.go
|
|
@ -27,8 +27,14 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
}
|
}
|
||||||
session.hidRPCAvailable = true
|
session.hidRPCAvailable = true
|
||||||
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
|
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
|
||||||
|
if !session.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcErr = handleHidRPCKeyboardInput(message)
|
rpcErr = handleHidRPCKeyboardInput(message)
|
||||||
case hidrpc.TypeKeyboardMacroReport:
|
case hidrpc.TypeKeyboardMacroReport:
|
||||||
|
if !session.HasPermission(PermissionPaste) {
|
||||||
|
return
|
||||||
|
}
|
||||||
keyboardMacroReport, err := message.KeyboardMacroReport()
|
keyboardMacroReport, err := message.KeyboardMacroReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
||||||
|
|
@ -36,18 +42,30 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
}
|
}
|
||||||
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
|
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
|
||||||
case hidrpc.TypeCancelKeyboardMacroReport:
|
case hidrpc.TypeCancelKeyboardMacroReport:
|
||||||
|
if !session.HasPermission(PermissionPaste) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcCancelKeyboardMacro()
|
rpcCancelKeyboardMacro()
|
||||||
return
|
return
|
||||||
case hidrpc.TypeKeypressKeepAliveReport:
|
case hidrpc.TypeKeypressKeepAliveReport:
|
||||||
|
if !session.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
||||||
case hidrpc.TypePointerReport:
|
case hidrpc.TypePointerReport:
|
||||||
|
if !session.HasPermission(PermissionMouseInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
pointerReport, err := message.PointerReport()
|
pointerReport, err := message.PointerReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get pointer report")
|
logger.Warn().Err(err).Msg("failed to get pointer report")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
|
rpcErr = rpcAbsMouseReport(int16(pointerReport.X), int16(pointerReport.Y), pointerReport.Button)
|
||||||
case hidrpc.TypeMouseReport:
|
case hidrpc.TypeMouseReport:
|
||||||
|
if !session.HasPermission(PermissionMouseInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mouseReport, err := message.MouseReport()
|
mouseReport, err := message.MouseReport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to get mouse report")
|
logger.Warn().Err(err).Msg("failed to get mouse report")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -354,7 +354,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
|
||||||
u.keyboardStateLock.Unlock()
|
u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
if u.onKeysDownChange != nil {
|
if u.onKeysDownChange != nil {
|
||||||
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
|
(*u.onKeysDownChange)(state)
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
|
func (u *UsbGadget) AbsMouseReport(x int16, y int16, buttons uint8) error {
|
||||||
u.absMouseLock.Lock()
|
u.absMouseLock.Lock()
|
||||||
defer u.absMouseLock.Unlock()
|
defer u.absMouseLock.Unlock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,12 @@ func runJiggler() {
|
||||||
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
|
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
|
||||||
logger.Debug().Msg("Jiggling mouse...")
|
logger.Debug().Msg("Jiggling mouse...")
|
||||||
//TODO: change to rel 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 {
|
if err != nil {
|
||||||
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
|
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
|
||||||
}
|
}
|
||||||
err = rpcAbsMouseReport(0, 0, 0)
|
err = gadget.AbsMouseReport(0, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
|
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
329
jsonrpc.go
329
jsonrpc.go
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -23,6 +24,14 @@ import (
|
||||||
"github.com/jetkvm/kvm/internal/utils"
|
"github.com/jetkvm/kvm/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nicknameRegex defines the valid pattern for nicknames (matching frontend validation)
|
||||||
|
var nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.@]+$`)
|
||||||
|
|
||||||
|
// isValidNickname checks if a nickname contains only valid characters
|
||||||
|
func isValidNickname(nickname string) bool {
|
||||||
|
return nicknameRegex.MatchString(nickname)
|
||||||
|
}
|
||||||
|
|
||||||
type JSONRPCRequest struct {
|
type JSONRPCRequest struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
|
@ -47,6 +56,7 @@ type DisplayRotationSettings struct {
|
||||||
Rotation string `json:"rotation"`
|
Rotation string `json:"rotation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type BacklightSettings struct {
|
type BacklightSettings struct {
|
||||||
MaxBrightness int `json:"max_brightness"`
|
MaxBrightness int `json:"max_brightness"`
|
||||||
DimAfter int `json:"dim_after"`
|
DimAfter int `json:"dim_after"`
|
||||||
|
|
@ -54,11 +64,16 @@ type BacklightSettings struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
|
if session == nil || session.RPCChannel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
responseBytes, err := json.Marshal(response)
|
responseBytes, err := json.Marshal(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(string(responseBytes))
|
err = session.RPCChannel.SendText(string(responseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
||||||
|
|
@ -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) {
|
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
|
var request JSONRPCRequest
|
||||||
err := json.Unmarshal(message.Data, &request)
|
err := json.Unmarshal(message.Data, &request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -124,6 +162,189 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
|
|
||||||
scopedLogger.Trace().Msg("Received RPC request")
|
scopedLogger.Trace().Msg("Received RPC request")
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
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]
|
handler, ok := rpcHandlers[request.Method]
|
||||||
if !ok {
|
if !ok {
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
|
|
@ -137,8 +358,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
writeJSONRPCResponse(errorResponse, session)
|
writeJSONRPCResponse(errorResponse, session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
result, handlerErr = callRPCHandler(scopedLogger, handler, request.Params)
|
||||||
|
}
|
||||||
|
|
||||||
result, err := callRPCHandler(scopedLogger, handler, request.Params)
|
err = handlerErr
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
|
|
@ -154,7 +377,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
|
scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully")
|
||||||
|
|
||||||
response := JSONRPCResponse{
|
response := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
|
|
@ -1084,6 +1307,93 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetSessions() ([]SessionData, error) {
|
||||||
|
return sessionManager.GetAllSessions(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetSessionData(sessionId string) (SessionData, error) {
|
||||||
|
session := sessionManager.GetSession(sessionId)
|
||||||
|
if session == nil {
|
||||||
|
return SessionData{}, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return SessionData{
|
||||||
|
ID: session.ID,
|
||||||
|
Mode: session.Mode,
|
||||||
|
Source: session.Source,
|
||||||
|
Identity: session.Identity,
|
||||||
|
CreatedAt: session.CreatedAt,
|
||||||
|
LastActive: session.LastActive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcRequestPrimary(sessionId string) map[string]interface{} {
|
||||||
|
err := sessionManager.RequestPrimary(sessionId)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the session was immediately promoted or queued
|
||||||
|
session := sessionManager.GetSession(sessionId)
|
||||||
|
if session == nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": "session not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"mode": string(session.Mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcReleasePrimary(sessionId string) error {
|
||||||
|
return sessionManager.ReleasePrimary(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcTransferPrimary(fromId string, toId string) error {
|
||||||
|
return sessionManager.TransferPrimary(fromId, toId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func rpcGetSessionConfig() (map[string]interface{}, error) {
|
||||||
|
maxSessions := 10
|
||||||
|
primaryTimeout := 300
|
||||||
|
|
||||||
|
if config != nil && config.MultiSession != nil {
|
||||||
|
if config.MultiSession.MaxSessions > 0 {
|
||||||
|
maxSessions = config.MultiSession.MaxSessions
|
||||||
|
}
|
||||||
|
if config.MultiSession.PrimaryTimeout > 0 {
|
||||||
|
primaryTimeout = config.MultiSession.PrimaryTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"maxSessions": maxSessions,
|
||||||
|
"primaryTimeout": primaryTimeout,
|
||||||
|
"allowCloudOverride": true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
var (
|
||||||
keyboardMacroCancel context.CancelFunc
|
keyboardMacroCancel context.CancelFunc
|
||||||
keyboardMacroLock sync.Mutex
|
keyboardMacroLock sync.Mutex
|
||||||
|
|
@ -1119,8 +1429,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
||||||
IsPaste: true,
|
IsPaste: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentSession != nil {
|
// Report to primary session if exists
|
||||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
|
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
||||||
|
|
@ -1128,8 +1439,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
||||||
setKeyboardMacroCancel(nil)
|
setKeyboardMacroCancel(nil)
|
||||||
|
|
||||||
s.State = false
|
s.State = false
|
||||||
if currentSession != nil {
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
currentSession.reportHidRPCKeyboardMacroState(s)
|
primarySession.reportHidRPCKeyboardMacroState(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
@ -1267,4 +1578,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||||
|
"getSessions": {Func: rpcGetSessions},
|
||||||
|
"getSessionData": {Func: rpcGetSessionData, Params: []string{"sessionId"}},
|
||||||
|
"getSessionConfig": {Func: rpcGetSessionConfig},
|
||||||
|
"requestPrimary": {Func: rpcRequestPrimary, Params: []string{"sessionId"}},
|
||||||
|
"releasePrimary": {Func: rpcReleasePrimary, Params: []string{"sessionId"}},
|
||||||
|
"transferPrimary": {Func: rpcTransferPrimary, Params: []string{"fromId", "toId"}},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
main.go
15
main.go
|
|
@ -16,6 +16,18 @@ var appCtx context.Context
|
||||||
func Main() {
|
func Main() {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|
||||||
|
// Initialize currentSessionSettings to use config's persistent SessionSettings
|
||||||
|
if config.SessionSettings == nil {
|
||||||
|
config.SessionSettings = &SessionSettings{
|
||||||
|
RequireApproval: false,
|
||||||
|
RequireNickname: false,
|
||||||
|
ReconnectGrace: 10,
|
||||||
|
PrivateKeystrokes: false,
|
||||||
|
}
|
||||||
|
SaveConfig()
|
||||||
|
}
|
||||||
|
currentSessionSettings = config.SessionSettings
|
||||||
|
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -91,7 +103,8 @@ func Main() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentSession != nil {
|
// Skip update if there's an active primary session
|
||||||
|
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
|
||||||
logger.Debug().Msg("skipping update since a session is active")
|
logger.Debug().Msg("skipping update since a session is active")
|
||||||
time.Sleep(1 * time.Minute)
|
time.Sleep(1 * time.Minute)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
15
native.go
15
native.go
|
|
@ -48,12 +48,21 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
|
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
|
||||||
if currentSession != nil {
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
|
if !sessionManager.CanReceiveVideo(s, currentSessionSettings) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.VideoTrack != nil {
|
||||||
|
err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nativeLogger.Warn().Err(err).Msg("error writing sample")
|
nativeLogger.Warn().
|
||||||
|
Str("sessionID", s.ID).
|
||||||
|
Err(err).
|
||||||
|
Msg("error writing sample to session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
nativeInstance.Start()
|
nativeInstance.Start()
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,7 @@ func initNetwork() error {
|
||||||
},
|
},
|
||||||
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
|
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
|
||||||
networkStateChanged(state.IsOnline())
|
networkStateChanged(state.IsOnline())
|
||||||
|
broadcastJSONRPCEvent("networkState", networkState.RpcGetNetworkState())
|
||||||
if currentSession == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
|
|
||||||
},
|
},
|
||||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||||
config.NetworkConfig = networkConfig
|
config.NetworkConfig = networkConfig
|
||||||
|
|
|
||||||
6
ota.go
6
ota.go
|
|
@ -302,11 +302,7 @@ var otaState = OTAState{}
|
||||||
|
|
||||||
func triggerOTAStateUpdate() {
|
func triggerOTAStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
if currentSession == nil {
|
broadcastJSONRPCEvent("otaState", otaState)
|
||||||
logger.Info().Msg("No active RPC session, skipping update state update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
serial.go
21
serial.go
|
|
@ -57,12 +57,10 @@ func runATXControl() {
|
||||||
newBtnRSTState := line[2] == '1'
|
newBtnRSTState := line[2] == '1'
|
||||||
newBtnPWRState := line[3] == '1'
|
newBtnPWRState := line[3] == '1'
|
||||||
|
|
||||||
if currentSession != nil {
|
broadcastJSONRPCEvent("atxState", ATXState{
|
||||||
writeJSONRPCEvent("atxState", ATXState{
|
|
||||||
Power: newLedPWRState,
|
Power: newLedPWRState,
|
||||||
HDD: newLedHDDState,
|
HDD: newLedHDDState,
|
||||||
}, currentSession)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if newLedHDDState != ledHDDState ||
|
if newLedHDDState != ledHDDState ||
|
||||||
newLedPWRState != ledPWRState ||
|
newLedPWRState != ledPWRState ||
|
||||||
|
|
@ -210,9 +208,7 @@ func runDCControl() {
|
||||||
// Update Prometheus metrics
|
// Update Prometheus metrics
|
||||||
updateDCMetrics(dcState)
|
updateDCMetrics(dcState)
|
||||||
|
|
||||||
if currentSession != nil {
|
broadcastJSONRPCEvent("dcState", dcState)
|
||||||
writeJSONRPCEvent("dcState", dcState, currentSession)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,9 +280,16 @@ func reopenSerialPort() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSerialChannel(d *webrtc.DataChannel) {
|
func handleSerialChannel(d *webrtc.DataChannel, session *Session) {
|
||||||
scopedLogger := serialLogger.With().
|
scopedLogger := serialLogger.With().
|
||||||
Uint16("data_channel_id", *d.ID()).Logger()
|
Uint16("data_channel_id", *d.ID()).
|
||||||
|
Str("session_id", session.ID).Logger()
|
||||||
|
|
||||||
|
// Check serial access permission
|
||||||
|
if !session.HasPermission(PermissionSerialAccess) {
|
||||||
|
handlePermissionDeniedChannel(d, "Serial port access denied: Permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
d.OnOpen(func() {
|
d.OnOpen(func() {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jetkvm/kvm/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Permission = session.Permission
|
||||||
|
PermissionSet = session.PermissionSet
|
||||||
|
SessionMode = session.SessionMode
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionModePrimary = session.SessionModePrimary
|
||||||
|
SessionModeObserver = session.SessionModeObserver
|
||||||
|
SessionModeQueued = session.SessionModeQueued
|
||||||
|
SessionModePending = session.SessionModePending
|
||||||
|
|
||||||
|
PermissionVideoView = session.PermissionVideoView
|
||||||
|
PermissionKeyboardInput = session.PermissionKeyboardInput
|
||||||
|
PermissionMouseInput = session.PermissionMouseInput
|
||||||
|
PermissionPaste = session.PermissionPaste
|
||||||
|
PermissionSessionTransfer = session.PermissionSessionTransfer
|
||||||
|
PermissionSessionApprove = session.PermissionSessionApprove
|
||||||
|
PermissionSessionKick = session.PermissionSessionKick
|
||||||
|
PermissionSessionRequestPrimary = session.PermissionSessionRequestPrimary
|
||||||
|
PermissionSessionReleasePrimary = session.PermissionSessionReleasePrimary
|
||||||
|
PermissionSessionManage = session.PermissionSessionManage
|
||||||
|
PermissionPowerControl = session.PermissionPowerControl
|
||||||
|
PermissionUSBControl = session.PermissionUSBControl
|
||||||
|
PermissionMountMedia = session.PermissionMountMedia
|
||||||
|
PermissionUnmountMedia = session.PermissionUnmountMedia
|
||||||
|
PermissionMountList = session.PermissionMountList
|
||||||
|
PermissionExtensionManage = session.PermissionExtensionManage
|
||||||
|
PermissionExtensionATX = session.PermissionExtensionATX
|
||||||
|
PermissionExtensionDC = session.PermissionExtensionDC
|
||||||
|
PermissionExtensionSerial = session.PermissionExtensionSerial
|
||||||
|
PermissionExtensionWOL = session.PermissionExtensionWOL
|
||||||
|
PermissionTerminalAccess = session.PermissionTerminalAccess
|
||||||
|
PermissionSerialAccess = session.PermissionSerialAccess
|
||||||
|
PermissionSettingsRead = session.PermissionSettingsRead
|
||||||
|
PermissionSettingsWrite = session.PermissionSettingsWrite
|
||||||
|
PermissionSettingsAccess = session.PermissionSettingsAccess
|
||||||
|
PermissionSystemReboot = session.PermissionSystemReboot
|
||||||
|
PermissionSystemUpdate = session.PermissionSystemUpdate
|
||||||
|
PermissionSystemNetwork = session.PermissionSystemNetwork
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
GetMethodPermission = session.GetMethodPermission
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetPermissionsResponse = session.GetPermissionsResponse
|
||||||
|
|
||||||
|
func (s *Session) HasPermission(perm Permission) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.CheckPermission(s.Mode, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) GetPermissions() PermissionSet {
|
||||||
|
if s == nil {
|
||||||
|
return PermissionSet{}
|
||||||
|
}
|
||||||
|
return session.GetPermissionsForMode(s.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirePermission(s *Session, perm Permission) error {
|
||||||
|
if s == nil {
|
||||||
|
return session.RequirePermissionForMode(SessionModePending, perm)
|
||||||
|
}
|
||||||
|
if !s.HasPermission(perm) {
|
||||||
|
return session.RequirePermissionForMode(s.Mode, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
terminal.go
11
terminal.go
|
|
@ -16,9 +16,16 @@ type TerminalSize struct {
|
||||||
Cols int `json:"cols"`
|
Cols int `json:"cols"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTerminalChannel(d *webrtc.DataChannel) {
|
func handleTerminalChannel(d *webrtc.DataChannel, session *Session) {
|
||||||
scopedLogger := terminalLogger.With().
|
scopedLogger := terminalLogger.With().
|
||||||
Uint16("data_channel_id", *d.ID()).Logger()
|
Uint16("data_channel_id", *d.ID()).
|
||||||
|
Str("session_id", session.ID).Logger()
|
||||||
|
|
||||||
|
// Check terminal access permission
|
||||||
|
if !session.HasPermission(PermissionTerminalAccess) {
|
||||||
|
handlePermissionDeniedChannel(d, "Terminal access denied: Permission required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var ptmx *os.File
|
var ptmx *os.File
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@ import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback, useRef, useEffect } from "react";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,7 +18,11 @@ import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
import MountPopopover from "@/components/popovers/MountPopover";
|
import MountPopopover from "@/components/popovers/MountPopover";
|
||||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
|
import SessionPopover from "@/components/popovers/SessionPopover";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
|
|
@ -33,6 +37,37 @@ export default function Actionbar({
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
const { developerMode } = useSettingsStore();
|
const { developerMode } = useSettingsStore();
|
||||||
|
const { currentMode, sessions, setSessions } = useSessionStore();
|
||||||
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch sessions on mount if we have an RPC channel
|
||||||
|
useEffect(() => {
|
||||||
|
if (rpcDataChannel?.readyState === "open" && sessions.length === 0) {
|
||||||
|
const id = Math.random().toString(36).substring(2);
|
||||||
|
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id });
|
||||||
|
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(event.data);
|
||||||
|
if (response.id === id && response.result) {
|
||||||
|
setSessions(response.result);
|
||||||
|
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
|
// This is the only way to get a reliable state change for the popover
|
||||||
// at time of writing this there is no mount, or unmount event for the popover
|
// at time of writing this there is no mount, or unmount event for the popover
|
||||||
|
|
@ -44,7 +79,6 @@ export default function Actionbar({
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
console.debug("Popover is closing. Returning focus trap to video");
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +103,7 @@ export default function Actionbar({
|
||||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission(Permission.PASTE) && (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -99,6 +134,8 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.MOUNT_MEDIA) && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
|
|
@ -142,6 +179,8 @@ export default function Actionbar({
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.EXTENSION_WOL) && (
|
||||||
<div>
|
<div>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
|
|
@ -194,6 +233,8 @@ export default function Actionbar({
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
@ -203,9 +244,59 @@ export default function Actionbar({
|
||||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
<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={sessions.length > 0 ? `Sessions (${sessions.length})` : "Sessions"}
|
||||||
|
LeadingIcon={({ className }) => {
|
||||||
|
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 end"
|
||||||
|
transition
|
||||||
|
className={cx(
|
||||||
|
"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 <SessionPopover />;
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPermission(Permission.EXTENSION_MANAGE) && (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverButton as={Fragment}>
|
<PopoverButton as={Fragment}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -232,7 +323,9 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
||||||
<div className="block lg:hidden">
|
<div className="block lg:hidden">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
@ -242,6 +335,7 @@ export default function Actionbar({
|
||||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
@ -258,6 +352,8 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only show Settings for sessions with settings access */}
|
||||||
|
{hasPermission(Permission.SETTINGS_ACCESS) && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
@ -270,6 +366,7 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden items-center gap-x-2 lg:flex">
|
<div className="hidden items-center gap-x-2 lg:flex">
|
||||||
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import USBStateStatus from "@components/USBStateStatus";
|
import USBStateStatus from "@components/USBStateStatus";
|
||||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
|
@ -37,6 +38,8 @@ export default function DashboardNavbar({
|
||||||
}: NavbarProps) {
|
}: NavbarProps) {
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const setUser = useUserStore(state => state.setUser);
|
const setUser = useUserStore(state => state.setUser);
|
||||||
|
const { clearSession } = useSessionStore();
|
||||||
|
const { clearNickname } = useSharedSessionStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onLogout = useCallback(async () => {
|
const onLogout = useCallback(async () => {
|
||||||
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||||
|
|
@ -44,9 +47,12 @@ export default function DashboardNavbar({
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
// Clear the stored session data via zustand
|
||||||
|
clearNickname();
|
||||||
|
clearSession();
|
||||||
// The root route will redirect to appropriate login page, be it the local one or the cloud one
|
// The root route will redirect to appropriate login page, be it the local one or the cloud one
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}, [navigate, setUser]);
|
}, [navigate, setUser, clearNickname, clearSession]);
|
||||||
|
|
||||||
const { usbState } = useHidStore();
|
const { usbState } = useHidStore();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,25 @@ import Container from "@components/Container";
|
||||||
import { useMacrosStore } from "@/hooks/stores";
|
import { useMacrosStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
export default function MacroBar() {
|
export default function MacroBar() {
|
||||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||||
const { executeMacro } = useKeyboard();
|
const { executeMacro } = useKeyboard();
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
const { permissions, hasPermission } = usePermissions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSendFn(send);
|
setSendFn(send);
|
||||||
|
|
||||||
if (!initialized) {
|
// Only load macros if user has permission to read settings
|
||||||
|
if (!initialized && permissions[Permission.SETTINGS_READ] === true) {
|
||||||
loadMacros();
|
loadMacros();
|
||||||
}
|
}
|
||||||
}, [initialized, loadMacros, setSendFn, send]);
|
}, [initialized, send, loadMacros, setSendFn, permissions]);
|
||||||
|
|
||||||
if (macros.length === 0) {
|
// Don't show macros if user can't provide keyboard input or if no macros exist
|
||||||
|
if (macros.length === 0 || !hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ClockIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
interface PendingApprovalOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingApprovalOverlay({ show }: PendingApprovalOverlayProps) {
|
||||||
|
const [dots, setDots] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setDots(prev => (prev.length >= 3 ? "" : prev + "."));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<ClockIcon className="h-12 w-12 text-amber-500 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Awaiting Approval{dots}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Your session is pending approval from the primary session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 w-full">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300 text-center">
|
||||||
|
The primary user will receive a notification to approve or deny your access.
|
||||||
|
This typically takes less than 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="h-2 w-2 bg-amber-500 rounded-full animate-pulse" />
|
||||||
|
<span>Waiting for response from primary session</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
import useMouse from "@/hooks/useMouse";
|
import useMouse from "@/hooks/useMouse";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +37,8 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
// Store hooks
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const { currentMode } = useSessionStore();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
||||||
const {
|
const {
|
||||||
getRelMouseMoveHandler,
|
getRelMouseMoveHandler,
|
||||||
|
|
@ -214,29 +218,47 @@ export default function WebRTCVideo() {
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
}, [releaseKeyboardLock]);
|
}, [releaseKeyboardLock]);
|
||||||
|
|
||||||
const absMouseMoveHandler = useMemo(
|
const absMouseMoveHandler = useMemo(() => {
|
||||||
() => getAbsMouseMoveHandler({
|
const handler = getAbsMouseMoveHandler({
|
||||||
videoClientWidth,
|
videoClientWidth,
|
||||||
videoClientHeight,
|
videoClientHeight,
|
||||||
videoWidth,
|
videoWidth,
|
||||||
videoHeight,
|
videoHeight,
|
||||||
}),
|
});
|
||||||
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight],
|
return (e: MouseEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [currentMode, getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight]);
|
||||||
|
|
||||||
const relMouseMoveHandler = useMemo(
|
const relMouseMoveHandler = useMemo(() => {
|
||||||
() => getRelMouseMoveHandler(),
|
const handler = getRelMouseMoveHandler();
|
||||||
[getRelMouseMoveHandler],
|
return (e: MouseEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [currentMode, getRelMouseMoveHandler]);
|
||||||
|
|
||||||
const mouseWheelHandler = useMemo(
|
const mouseWheelHandler = useMemo(() => {
|
||||||
() => getMouseWheelHandler(),
|
const handler = getMouseWheelHandler();
|
||||||
[getMouseWheelHandler],
|
return (e: WheelEvent) => {
|
||||||
);
|
// Only allow input if user has mouse permission
|
||||||
|
if (!hasPermission(Permission.MOUSE_INPUT)) return;
|
||||||
|
handler(e);
|
||||||
|
};
|
||||||
|
}, [currentMode, getMouseWheelHandler]);
|
||||||
|
|
||||||
const keyDownHandler = useCallback(
|
const keyDownHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only allow input if user has keyboard permission
|
||||||
|
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
const code = getAdjustedKeyCode(e);
|
const code = getAdjustedKeyCode(e);
|
||||||
const hidKey = keys[code];
|
const hidKey = keys[code];
|
||||||
|
|
@ -252,11 +274,9 @@ export default function WebRTCVideo() {
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
||||||
if (e.metaKey && hidKey < 0xE0) {
|
if (e.metaKey && hidKey < 0xE0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
console.debug(`Key down: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, true);
|
handleKeyPress(hidKey, true);
|
||||||
|
|
||||||
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
||||||
|
|
@ -264,17 +284,22 @@ export default function WebRTCVideo() {
|
||||||
// we'll never see the keyup event because the browser is going to lose
|
// we'll never see the keyup event because the browser is going to lose
|
||||||
// focus so set a deferred keyup after a short delay
|
// focus so set a deferred keyup after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.debug(`Forcing the left meta key release`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleKeyPress, isKeyboardLockActive],
|
[currentMode, handleKeyPress, isKeyboardLockActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyUpHandler = useCallback(
|
const keyUpHandler = useCallback(
|
||||||
async (e: KeyboardEvent) => {
|
async (e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only allow input if user has keyboard permission
|
||||||
|
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const code = getAdjustedKeyCode(e);
|
const code = getAdjustedKeyCode(e);
|
||||||
const hidKey = keys[code];
|
const hidKey = keys[code];
|
||||||
|
|
||||||
|
|
@ -283,10 +308,9 @@ export default function WebRTCVideo() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`Key up: ${hidKey}`);
|
|
||||||
handleKeyPress(hidKey, false);
|
handleKeyPress(hidKey, false);
|
||||||
},
|
},
|
||||||
[handleKeyPress],
|
[currentMode, handleKeyPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
|
@ -297,7 +321,6 @@ export default function WebRTCVideo() {
|
||||||
// Fix only works in chrome based browsers.
|
// Fix only works in chrome based browsers.
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") {
|
||||||
if (videoElm.current.paused) {
|
if (videoElm.current.paused) {
|
||||||
console.debug("Force playing video");
|
|
||||||
videoElm.current.play();
|
videoElm.current.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -556,7 +579,7 @@ export default function WebRTCVideo() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
{hasPermission(Permission.KEYBOARD_INPUT) && <VirtualKeyboard />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -329,6 +329,12 @@ export interface SettingsState {
|
||||||
developerMode: boolean;
|
developerMode: boolean;
|
||||||
setDeveloperMode: (enabled: boolean) => void;
|
setDeveloperMode: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
requireSessionNickname: boolean;
|
||||||
|
setRequireSessionNickname: (required: boolean) => void;
|
||||||
|
|
||||||
|
requireSessionApproval: boolean;
|
||||||
|
setRequireSessionApproval: (required: boolean) => void;
|
||||||
|
|
||||||
displayRotation: string;
|
displayRotation: string;
|
||||||
setDisplayRotation: (rotation: string) => void;
|
setDisplayRotation: (rotation: string) => void;
|
||||||
|
|
||||||
|
|
@ -369,6 +375,12 @@ export const useSettingsStore = create(
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
|
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
|
||||||
|
|
||||||
|
requireSessionNickname: false,
|
||||||
|
setRequireSessionNickname: (required: boolean) => set({ requireSessionNickname: required }),
|
||||||
|
|
||||||
|
requireSessionApproval: true,
|
||||||
|
setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }),
|
||||||
|
|
||||||
displayRotation: "270",
|
displayRotation: "270",
|
||||||
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
|
@ -36,6 +36,12 @@ let requestCounter = 0;
|
||||||
|
|
||||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const onRequestRef = useRef(onRequest);
|
||||||
|
|
||||||
|
// Update ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
onRequestRef.current = onRequest;
|
||||||
|
}, [onRequest]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||||
|
|
@ -59,7 +65,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
// The "API" can also "request" data from the client
|
// The "API" can also "request" data from the client
|
||||||
// If the payload has a method, it's a request
|
// If the payload has a method, it's a request
|
||||||
if ("method" in payload) {
|
if ("method" in payload) {
|
||||||
if (onRequest) onRequest(payload);
|
if (onRequestRef.current) onRequestRef.current(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +85,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
rpcDataChannel.removeEventListener("message", messageHandler);
|
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[rpcDataChannel, onRequest]);
|
[rpcDataChannel]); // Remove onRequest from dependencies
|
||||||
|
|
||||||
return { send };
|
return { send };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.sett
|
||||||
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
|
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
|
||||||
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
|
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
|
||||||
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
|
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
|
||||||
|
const SettingsMultiSessionsRoute = lazy(() => import("@routes/devices.$id.settings.multi-session"));
|
||||||
|
|
||||||
export const isOnDevice = import.meta.env.MODE === "device";
|
export const isOnDevice = import.meta.env.MODE === "device";
|
||||||
export const isInCloud = !isOnDevice;
|
export const isInCloud = !isOnDevice;
|
||||||
|
|
@ -211,6 +212,10 @@ if (isOnDevice) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "sessions",
|
||||||
|
element: <SettingsMultiSessionsRoute />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -344,6 +349,10 @@ if (isOnDevice) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "sessions",
|
||||||
|
element: <SettingsMultiSessionsRoute />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
ExclamationTriangleIcon
|
||||||
|
} from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
|
|
@ -57,6 +62,32 @@ const notifications = {
|
||||||
{ duration: 2000, ...options },
|
{ duration: 2000, ...options },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
info: (message: string, options?: NotificationOptions) => {
|
||||||
|
return toast.custom(
|
||||||
|
t => (
|
||||||
|
<ToastContent
|
||||||
|
icon={<InformationCircleIcon className="w-5 h-5 text-blue-500 dark:text-blue-400" />}
|
||||||
|
message={message}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 2000, ...options },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
warning: (message: string, options?: NotificationOptions) => {
|
||||||
|
return toast.custom(
|
||||||
|
t => (
|
||||||
|
<ToastContent
|
||||||
|
icon={<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500 dark:text-yellow-400" />}
|
||||||
|
message={message}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ duration: 3000, ...options },
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function useMaxToasts(max: number) {
|
function useMaxToasts(max: number) {
|
||||||
|
|
@ -82,7 +113,12 @@ export function Notifications({
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export default Object.assign(Notifications, {
|
export const notify = {
|
||||||
success: notifications.success,
|
success: notifications.success,
|
||||||
error: notifications.error,
|
error: notifications.error,
|
||||||
});
|
info: notifications.info,
|
||||||
|
warning: notifications.warning,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export default Object.assign(Notifications, notify);
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
if ("error" in resp) return console.error(resp.error);
|
if ("error" in resp) return console.error(resp.error);
|
||||||
setDeviceId(resp.result as string);
|
setDeviceId(resp.result as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
}, [send, getCloudState, getTLSState]);
|
}, [send, getCloudState, getTLSState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -327,6 +328,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title="Remote"
|
title="Remote"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||||
|
|
@ -15,6 +16,7 @@ export default function SettingsHardwareRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { setDisplayRotation } = useSettingsStore();
|
const { setDisplayRotation } = useSettingsStore();
|
||||||
|
const { hasPermission, isLoading, permissions } = usePermissions();
|
||||||
|
|
||||||
const handleDisplayRotationChange = (rotation: string) => {
|
const handleDisplayRotationChange = (rotation: string) => {
|
||||||
setDisplayRotation(rotation);
|
setDisplayRotation(rotation);
|
||||||
|
|
@ -58,7 +60,10 @@ export default function SettingsHardwareRoute() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check permissions before fetching settings data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch settings if user has permission
|
||||||
|
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
|
||||||
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return notifications.error(
|
return notifications.error(
|
||||||
|
|
@ -68,7 +73,26 @@ export default function SettingsHardwareRoute() {
|
||||||
const result = resp.result as BacklightSettings;
|
const result = resp.result as BacklightSettings;
|
||||||
setBacklightSettings(result);
|
setBacklightSettings(result);
|
||||||
});
|
});
|
||||||
}, [send, setBacklightSettings]);
|
}
|
||||||
|
}, [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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,19 +12,33 @@ import {
|
||||||
LuPalette,
|
LuPalette,
|
||||||
LuCommand,
|
LuCommand,
|
||||||
LuNetwork,
|
LuNetwork,
|
||||||
|
LuUsers,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { useResizeObserver } from "usehooks-ts";
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { LinkButton } from "@components/Button";
|
import { LinkButton } from "@components/Button";
|
||||||
import { FeatureFlag } from "@components/FeatureFlag";
|
import { FeatureFlag } from "@components/FeatureFlag";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { usePermissions, 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. */
|
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||||
export default function SettingsRoute() {
|
export default function SettingsRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setDisableVideoFocusTrap } = useUiStore();
|
const { setDisableVideoFocusTrap } = useUiStore();
|
||||||
|
const { currentMode } = useSessionStore();
|
||||||
|
const { hasPermission, isLoading, permissions } = usePermissions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
||||||
|
navigate("/devices/local", { replace: true });
|
||||||
|
}
|
||||||
|
}, [permissions, isLoading, currentMode, navigate]);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||||
|
|
@ -69,6 +83,21 @@ export default function SettingsRoute() {
|
||||||
};
|
};
|
||||||
}, [setDisableVideoFocusTrap]);
|
}, [setDisableVideoFocusTrap]);
|
||||||
|
|
||||||
|
// Check permissions first - return early to prevent any content flash
|
||||||
|
// Show loading state while permissions are being checked
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-500">Checking permissions...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render settings content if user doesn't have permission
|
||||||
|
if (!hasPermission(Permission.SETTINGS_ACCESS)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
@ -223,6 +252,17 @@ export default function SettingsRoute() {
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<NavLink
|
||||||
|
to="sessions"
|
||||||
|
className={({ isActive }) => (isActive ? "active" : "")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
|
||||||
|
<LuUsers className="h-4 w-4 shrink-0" />
|
||||||
|
<h1>Multi-Session Access</h1>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="advanced"
|
to="advanced"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import useWebSocket from "react-use-websocket";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
KeyboardLedState,
|
KeyboardLedState,
|
||||||
|
|
@ -29,12 +30,17 @@ import {
|
||||||
useNetworkStateStore,
|
useNetworkStateStore,
|
||||||
User,
|
User,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useUpdateStore,
|
useUpdateStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
VideoState,
|
VideoState,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import WebRTCVideo from "@components/WebRTCVideo";
|
import WebRTCVideo from "@components/WebRTCVideo";
|
||||||
|
import UnifiedSessionRequestDialog from "@components/UnifiedSessionRequestDialog";
|
||||||
|
import NicknameModal from "@components/NicknameModal";
|
||||||
|
import AccessDeniedOverlay from "@components/AccessDeniedOverlay";
|
||||||
|
import PendingApprovalOverlay from "@components/PendingApprovalOverlay";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
||||||
const Terminal = lazy(() => import('@components/Terminal'));
|
const Terminal = lazy(() => import('@components/Terminal'));
|
||||||
|
|
@ -50,6 +56,9 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
||||||
import { DeviceStatus } from "@routes/welcome-local";
|
import { DeviceStatus } from "@routes/welcome-local";
|
||||||
import { useVersion } from "@/hooks/useVersion";
|
import { useVersion } from "@/hooks/useVersion";
|
||||||
|
import { useSessionManagement } from "@/hooks/useSessionManagement";
|
||||||
|
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
|
|
||||||
interface LocalLoaderResp {
|
interface LocalLoaderResp {
|
||||||
authMode: "password" | "noPassword" | null;
|
authMode: "password" | "noPassword" | null;
|
||||||
|
|
@ -122,7 +131,7 @@ export default function KvmIdRoute() {
|
||||||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||||
|
|
||||||
const params = useParams() as { id: string };
|
const params = useParams() as { id: string };
|
||||||
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
|
const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap } = useUiStore();
|
||||||
const [ queryParams, setQueryParams ] = useSearchParams();
|
const [ queryParams, setQueryParams ] = useSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -141,14 +150,20 @@ export default function KvmIdRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLegacySignalingEnabled = useRef(false);
|
const isLegacySignalingEnabled = useRef(false);
|
||||||
const [connectionFailed, setConnectionFailed] = useState(false);
|
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||||
|
const [showNicknameModal, setShowNicknameModal] = useState(false);
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||||
|
const { currentSessionId, currentMode, setCurrentSession } = useSessionStore();
|
||||||
|
const { nickname, setNickname } = useSharedSessionStore();
|
||||||
|
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
|
||||||
|
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||||
const cleanupAndStopReconnecting = useCallback(
|
const cleanupAndStopReconnecting = useCallback(
|
||||||
function cleanupAndStopReconnecting() {
|
function cleanupAndStopReconnecting() {
|
||||||
console.log("Closing peer connection");
|
|
||||||
|
|
||||||
setConnectionFailed(true);
|
setConnectionFailed(true);
|
||||||
if (peerConnection) {
|
if (peerConnection) {
|
||||||
|
|
@ -186,7 +201,6 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||||
console.log("[setRemoteSessionDescription] Remote description set successfully");
|
|
||||||
setLoadingMessage("Establishing secure connection...");
|
setLoadingMessage("Establishing secure connection...");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -204,7 +218,6 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||||
if (pc.sctp?.state === "connected") {
|
if (pc.sctp?.state === "connected") {
|
||||||
console.log("[setRemoteSessionDescription] Remote description set");
|
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
setLoadingMessage("Connection established");
|
setLoadingMessage("Connection established");
|
||||||
} else if (attempts >= 10) {
|
} else if (attempts >= 10) {
|
||||||
|
|
@ -218,10 +231,6 @@ export default function KvmIdRoute() {
|
||||||
cleanupAndStopReconnecting();
|
cleanupAndStopReconnecting();
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
} else {
|
} else {
|
||||||
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
|
|
||||||
connectionState: pc.connectionState,
|
|
||||||
iceConnectionState: pc.iceConnectionState,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
|
|
@ -244,18 +253,15 @@ export default function KvmIdRoute() {
|
||||||
reconnectAttempts: 15,
|
reconnectAttempts: 15,
|
||||||
reconnectInterval: 1000,
|
reconnectInterval: 1000,
|
||||||
onReconnectStop: () => {
|
onReconnectStop: () => {
|
||||||
console.debug("Reconnect stopped");
|
|
||||||
cleanupAndStopReconnecting();
|
cleanupAndStopReconnecting();
|
||||||
},
|
},
|
||||||
|
|
||||||
shouldReconnect(event) {
|
shouldReconnect(_event) {
|
||||||
console.debug("[Websocket] shouldReconnect", event);
|
|
||||||
// TODO: Why true?
|
// TODO: Why true?
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(event) {
|
onClose(_event) {
|
||||||
console.debug("[Websocket] onClose", event);
|
|
||||||
// We don't want to close everything down, we wait for the reconnect to stop instead
|
// 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
|
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||||
},
|
},
|
||||||
onOpen() {
|
onOpen() {
|
||||||
console.debug("[Websocket] onOpen");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onMessage: message => {
|
onMessage: message => {
|
||||||
|
|
@ -285,27 +290,49 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const parsedMessage = JSON.parse(message.data);
|
const parsedMessage = JSON.parse(message.data);
|
||||||
if (parsedMessage.type === "device-metadata") {
|
if (parsedMessage.type === "device-metadata") {
|
||||||
const { deviceVersion } = parsedMessage.data;
|
const { deviceVersion, sessionSettings } = parsedMessage.data;
|
||||||
console.debug("[Websocket] Received device-metadata message");
|
|
||||||
console.debug("[Websocket] Device version", deviceVersion);
|
// Store session settings if provided
|
||||||
|
if (sessionSettings) {
|
||||||
|
setGlobalSessionSettings({
|
||||||
|
requireNickname: sessionSettings.requireNickname || false,
|
||||||
|
requireApproval: sessionSettings.requireApproval || false
|
||||||
|
});
|
||||||
|
// Also update the settings store for approval handling
|
||||||
|
setRequireSessionApproval(sessionSettings.requireApproval || false);
|
||||||
|
setRequireSessionNickname(sessionSettings.requireNickname || false);
|
||||||
|
}
|
||||||
|
|
||||||
// If the device version is not set, we can assume the device is using the legacy signaling
|
// If the device version is not set, we can assume the device is using the legacy signaling
|
||||||
if (!deviceVersion) {
|
if (!deviceVersion) {
|
||||||
console.log("[Websocket] Device is using legacy signaling");
|
|
||||||
|
|
||||||
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
||||||
// which does everything over HTTP(at least from the perspective of the client)
|
// which does everything over HTTP(at least from the perspective of the client)
|
||||||
isLegacySignalingEnabled.current = true;
|
isLegacySignalingEnabled.current = true;
|
||||||
getWebSocket()?.close();
|
getWebSocket()?.close();
|
||||||
} else {
|
} else {
|
||||||
console.log("[Websocket] Device is using new signaling");
|
|
||||||
isLegacySignalingEnabled.current = false;
|
isLegacySignalingEnabled.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always setup peer connection first to establish RPC channel for nickname generation
|
||||||
setupPeerConnection();
|
setupPeerConnection();
|
||||||
|
|
||||||
|
// Check if nickname is required and not set - modal will be shown after RPC channel is ready
|
||||||
|
const requiresNickname = sessionSettings?.requireNickname || false;
|
||||||
|
|
||||||
|
if (requiresNickname && !nickname) {
|
||||||
|
// Store that we need to show the nickname modal once RPC is ready
|
||||||
|
// The useEffect in NicknameModal will handle waiting for RPC channel readiness
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!peerConnection) return;
|
if (!peerConnection) {
|
||||||
|
console.warn("[Websocket] Ignoring message because peerConnection is not ready:", parsedMessage.type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (parsedMessage.type === "answer") {
|
if (parsedMessage.type === "answer") {
|
||||||
console.debug("[Websocket] Received answer");
|
|
||||||
const readyForOffer =
|
const readyForOffer =
|
||||||
// If we're making an offer, we don't want to accept an answer
|
// If we're making an offer, we don't want to accept an answer
|
||||||
!makingOffer &&
|
!makingOffer &&
|
||||||
|
|
@ -319,14 +346,41 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// Set so we don't accept an answer while we're setting the remote description
|
// Set so we don't accept an answer while we're setting the remote description
|
||||||
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
||||||
console.debug(
|
|
||||||
"[Websocket] Setting remote answer pending",
|
|
||||||
isSettingRemoteAnswerPending.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sd = atob(parsedMessage.data);
|
const sd = atob(parsedMessage.data);
|
||||||
const remoteSessionDescription = JSON.parse(sd);
|
const remoteSessionDescription = JSON.parse(sd);
|
||||||
|
|
||||||
|
if (parsedMessage.sessionId && parsedMessage.mode) {
|
||||||
|
handleSessionResponse({
|
||||||
|
sessionId: parsedMessage.sessionId,
|
||||||
|
mode: parsedMessage.mode
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store sessionId via zustand (persists to sessionStorage for per-tab isolation)
|
||||||
|
setCurrentSession(parsedMessage.sessionId, parsedMessage.mode);
|
||||||
|
if (parsedMessage.requireNickname !== undefined && parsedMessage.requireApproval !== undefined) {
|
||||||
|
setGlobalSessionSettings({
|
||||||
|
requireNickname: parsedMessage.requireNickname,
|
||||||
|
requireApproval: parsedMessage.requireApproval
|
||||||
|
});
|
||||||
|
// Also update the settings store for approval handling
|
||||||
|
setRequireSessionApproval(parsedMessage.requireApproval);
|
||||||
|
setRequireSessionNickname(parsedMessage.requireNickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show nickname modal if:
|
||||||
|
// 1. Nickname is required by backend settings
|
||||||
|
// 2. We don't already have a nickname
|
||||||
|
// This happens even for pending sessions so the nickname is included in approval
|
||||||
|
const hasNickname = parsedMessage.nickname && parsedMessage.nickname.length > 0;
|
||||||
|
const requiresNickname = parsedMessage.requireNickname || globalSessionSettings?.requireNickname;
|
||||||
|
|
||||||
|
if (requiresNickname && !hasNickname) {
|
||||||
|
setShowNicknameModal(true);
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRemoteSessionDescription(
|
setRemoteSessionDescription(
|
||||||
peerConnection,
|
peerConnection,
|
||||||
new RTCSessionDescription(remoteSessionDescription),
|
new RTCSessionDescription(remoteSessionDescription),
|
||||||
|
|
@ -335,9 +389,11 @@ export default function KvmIdRoute() {
|
||||||
// Reset the remote answer pending flag
|
// Reset the remote answer pending flag
|
||||||
isSettingRemoteAnswerPending.current = false;
|
isSettingRemoteAnswerPending.current = false;
|
||||||
} else if (parsedMessage.type === "new-ice-candidate") {
|
} else if (parsedMessage.type === "new-ice-candidate") {
|
||||||
console.debug("[Websocket] Received new-ice-candidate");
|
|
||||||
const candidate = parsedMessage.data;
|
const candidate = parsedMessage.data;
|
||||||
peerConnection.addIceCandidate(candidate);
|
// Always try to add the ICE candidate - the browser will queue it internally if needed
|
||||||
|
peerConnection.addIceCandidate(candidate).catch(error => {
|
||||||
|
console.warn("[Websocket] Failed to add ICE candidate:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -350,9 +406,16 @@ export default function KvmIdRoute() {
|
||||||
(type: string, data: unknown) => {
|
(type: string, data: unknown) => {
|
||||||
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
||||||
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
||||||
sendMessage(JSON.stringify({ type, data }), false);
|
const message = JSON.stringify({ type, data });
|
||||||
|
const ws = getWebSocket();
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
sendMessage(message, false);
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSocket] WebSocket not open, queuing message:`, message);
|
||||||
|
sendMessage(message, true); // Queue the message
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[sendMessage],
|
[sendMessage, getWebSocket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const legacyHTTPSignaling = useCallback(
|
const legacyHTTPSignaling = useCallback(
|
||||||
|
|
@ -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
|
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
||||||
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||||
|
|
||||||
console.log("Trying to get remote session description");
|
|
||||||
setLoadingMessage(
|
setLoadingMessage(
|
||||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||||
);
|
);
|
||||||
const res = await api.POST(sessionUrl, {
|
const res = await api.POST(sessionUrl, {
|
||||||
sd,
|
sd,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
// When on device, we don't need to specify the device id, as it's already known
|
// When on device, we don't need to specify the device id, as it's already known
|
||||||
...(isOnDevice ? {} : { id: params.id }),
|
...(isOnDevice ? {} : { id: params.id }),
|
||||||
});
|
});
|
||||||
|
|
@ -381,7 +444,6 @@ export default function KvmIdRoute() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Successfully got Remote Session Description. Setting.");
|
|
||||||
setLoadingMessage("Setting remote session description...");
|
setLoadingMessage("Setting remote session description...");
|
||||||
|
|
||||||
const decodedSd = atob(json.sd);
|
const decodedSd = atob(json.sd);
|
||||||
|
|
@ -392,13 +454,11 @@ export default function KvmIdRoute() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const setupPeerConnection = useCallback(async () => {
|
const setupPeerConnection = useCallback(async () => {
|
||||||
console.debug("[setupPeerConnection] Setting up peer connection");
|
|
||||||
setConnectionFailed(false);
|
setConnectionFailed(false);
|
||||||
setLoadingMessage("Connecting to device...");
|
setLoadingMessage("Connecting to device...");
|
||||||
|
|
||||||
let pc: RTCPeerConnection;
|
let pc: RTCPeerConnection;
|
||||||
try {
|
try {
|
||||||
console.debug("[setupPeerConnection] Creating peer connection");
|
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
|
|
@ -408,7 +468,6 @@ export default function KvmIdRoute() {
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
console.debug("[setupPeerConnection] Peer connection created", pc);
|
|
||||||
setLoadingMessage("Setting up connection to device...");
|
setLoadingMessage("Setting up connection to device...");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
||||||
|
|
@ -420,13 +479,11 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// Set up event listeners and data channels
|
// Set up event listeners and data channels
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onnegotiationneeded = async () => {
|
pc.onnegotiationneeded = async () => {
|
||||||
try {
|
try {
|
||||||
console.debug("[setupPeerConnection] Creating offer");
|
|
||||||
makingOffer.current = true;
|
makingOffer.current = true;
|
||||||
|
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
|
|
@ -434,9 +491,19 @@ export default function KvmIdRoute() {
|
||||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||||
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||||
if (isNewSignalingEnabled) {
|
if (isNewSignalingEnabled) {
|
||||||
sendWebRTCSignal("offer", { sd: sd });
|
// Get nickname and sessionId from zustand stores
|
||||||
} else {
|
// sessionId is per-tab (sessionStorage), nickname is shared (localStorage)
|
||||||
console.log("Legacy signaling. Waiting for ICE Gathering to complete...");
|
const { currentSessionId: storeSessionId } = useSessionStore.getState();
|
||||||
|
const { nickname: storeNickname } = useSharedSessionStore.getState();
|
||||||
|
|
||||||
|
sendWebRTCSignal("offer", {
|
||||||
|
sd: sd,
|
||||||
|
sessionId: storeSessionId || undefined,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
sessionSettings: {
|
||||||
|
nickname: storeNickname || undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -450,15 +517,18 @@ export default function KvmIdRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicecandidate = ({ candidate }) => {
|
pc.onicecandidate = ({ candidate }) => {
|
||||||
if (!candidate) return;
|
if (!candidate) {
|
||||||
if (candidate.candidate === "") return;
|
return;
|
||||||
|
}
|
||||||
|
if (candidate.candidate === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendWebRTCSignal("new-ice-candidate", candidate);
|
sendWebRTCSignal("new-ice-candidate", candidate);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicegatheringstatechange = event => {
|
pc.onicegatheringstatechange = event => {
|
||||||
const pc = event.currentTarget as RTCPeerConnection;
|
const pc = event.currentTarget as RTCPeerConnection;
|
||||||
if (pc.iceGatheringState === "complete") {
|
if (pc.iceGatheringState === "complete") {
|
||||||
console.debug("ICE Gathering completed");
|
|
||||||
setLoadingMessage("ICE Gathering completed");
|
setLoadingMessage("ICE Gathering completed");
|
||||||
|
|
||||||
if (isLegacySignalingEnabled.current) {
|
if (isLegacySignalingEnabled.current) {
|
||||||
|
|
@ -466,7 +536,6 @@ export default function KvmIdRoute() {
|
||||||
legacyHTTPSignaling(pc);
|
legacyHTTPSignaling(pc);
|
||||||
}
|
}
|
||||||
} else if (pc.iceGatheringState === "gathering") {
|
} else if (pc.iceGatheringState === "gathering") {
|
||||||
console.debug("ICE Gathering Started");
|
|
||||||
setLoadingMessage("Gathering ICE candidates...");
|
setLoadingMessage("Gathering ICE candidates...");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -480,6 +549,44 @@ export default function KvmIdRoute() {
|
||||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||||
rpcDataChannel.onopen = () => {
|
rpcDataChannel.onopen = () => {
|
||||||
setRpcDataChannel(rpcDataChannel);
|
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");
|
const rpcHidChannel = pc.createDataChannel("hidrpc");
|
||||||
|
|
@ -609,42 +716,54 @@ export default function KvmIdRoute() {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
||||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||||
|
// 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") {
|
if (resp.method === "otherSessionConnected") {
|
||||||
navigateTo("/other-session");
|
navigateTo("/other-session");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.method === "usbState") {
|
if (resp.method === "usbState") {
|
||||||
const usbState = resp.params as unknown as USBStates;
|
const usbState = resp.params as unknown as USBStates;
|
||||||
console.debug("Setting USB state", usbState);
|
|
||||||
setUsbState(usbState);
|
setUsbState(usbState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "videoInputState") {
|
if (resp.method === "videoInputState") {
|
||||||
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
|
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
|
||||||
console.debug("Setting HDMI state", hdmiState);
|
|
||||||
setHdmiState(hdmiState);
|
setHdmiState(hdmiState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "networkState") {
|
if (resp.method === "networkState") {
|
||||||
console.debug("Setting network state", resp.params);
|
|
||||||
setNetworkState(resp.params as NetworkState);
|
setNetworkState(resp.params as NetworkState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "keyboardLedState") {
|
if (resp.method === "keyboardLedState") {
|
||||||
const ledState = resp.params as KeyboardLedState;
|
const ledState = resp.params as KeyboardLedState;
|
||||||
console.debug("Setting keyboard led state", ledState);
|
|
||||||
setKeyboardLedState(ledState);
|
setKeyboardLedState(ledState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "keysDownState") {
|
if (resp.method === "keysDownState") {
|
||||||
const downState = resp.params as KeysDownState;
|
const downState = resp.params as KeysDownState;
|
||||||
console.debug("Setting key down state:", downState);
|
|
||||||
setKeysDownState(downState);
|
setKeysDownState(downState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.method === "otaState") {
|
if (resp.method === "otaState") {
|
||||||
const otaState = resp.params as OtaState;
|
const otaState = resp.params as OtaState;
|
||||||
console.debug("Setting OTA state", otaState);
|
|
||||||
setOtaState(otaState);
|
setOtaState(otaState);
|
||||||
|
|
||||||
if (otaState.updating === true) {
|
if (otaState.updating === true) {
|
||||||
|
|
@ -670,13 +789,24 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSessionResponse,
|
||||||
|
handleRpcEvent,
|
||||||
|
primaryControlRequest,
|
||||||
|
handleApprovePrimaryRequest,
|
||||||
|
handleDenyPrimaryRequest,
|
||||||
|
closePrimaryControlRequest,
|
||||||
|
newSessionRequest,
|
||||||
|
handleApproveNewSession,
|
||||||
|
handleDenyNewSession,
|
||||||
|
closeNewSessionRequest
|
||||||
|
} = useSessionManagement(send);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
console.log("Requesting video state");
|
|
||||||
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
||||||
console.debug("Setting HDMI state", hdmiState);
|
|
||||||
setHdmiState(hdmiState);
|
setHdmiState(hdmiState);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||||
|
|
@ -687,7 +817,6 @@ export default function KvmIdRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needLedState) return;
|
if (!needLedState) return;
|
||||||
console.log("Requesting keyboard led state");
|
|
||||||
|
|
||||||
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
|
|
@ -695,7 +824,6 @@ export default function KvmIdRoute() {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
const ledState = resp.result as KeyboardLedState;
|
const ledState = resp.result as KeyboardLedState;
|
||||||
console.debug("Keyboard led state: ", ledState);
|
|
||||||
setKeyboardLedState(ledState);
|
setKeyboardLedState(ledState);
|
||||||
}
|
}
|
||||||
setNeedLedState(false);
|
setNeedLedState(false);
|
||||||
|
|
@ -708,7 +836,6 @@ export default function KvmIdRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needKeyDownState) return;
|
if (!needKeyDownState) return;
|
||||||
console.log("Requesting keys down state");
|
|
||||||
|
|
||||||
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
|
|
@ -722,7 +849,6 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const downState = resp.result as KeysDownState;
|
const downState = resp.result as KeysDownState;
|
||||||
console.debug("Keyboard key down state", downState);
|
|
||||||
setKeysDownState(downState);
|
setKeysDownState(downState);
|
||||||
}
|
}
|
||||||
setNeedKeyDownState(false);
|
setNeedKeyDownState(false);
|
||||||
|
|
@ -840,7 +966,11 @@ export default function KvmIdRoute() {
|
||||||
kvmName={deviceName ?? "JetKVM Device"}
|
kvmName={deviceName ?? "JetKVM Device"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
|
{/* Only show video feed if nickname is set (when required) and not pending approval */}
|
||||||
|
{(!showNicknameModal && currentMode !== "pending") ? (
|
||||||
|
<>
|
||||||
<WebRTCVideo />
|
<WebRTCVideo />
|
||||||
<div
|
<div
|
||||||
style={{ animationDuration: "500ms" }}
|
style={{ animationDuration: "500ms" }}
|
||||||
|
|
@ -850,6 +980,15 @@ export default function KvmIdRoute() {
|
||||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
<SidebarContainer sidebarView={sidebarView} />
|
<SidebarContainer sidebarView={sidebarView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -870,6 +1009,27 @@ export default function KvmIdRoute() {
|
||||||
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||||
<Outlet context={{ setupPeerConnection }} />
|
<Outlet context={{ setupPeerConnection }} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{kvmTerminal && (
|
{kvmTerminal && (
|
||||||
|
|
@ -879,6 +1039,60 @@ export default function KvmIdRoute() {
|
||||||
{serialConsole && (
|
{serialConsole && (
|
||||||
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
<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>
|
</FeatureFlagProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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
107
usb.go
|
|
@ -27,20 +27,43 @@ func initUsbGadget() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
|
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
|
||||||
if currentSession != nil {
|
// Check if keystrokes should be private
|
||||||
currentSession.reportHidRPCKeyboardLedState(state)
|
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
|
||||||
|
// Report to primary session only
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.reportHidRPCKeyboardLedState(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Report to all authorized sessions (primary and observers, but not pending)
|
||||||
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
|
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
|
||||||
|
s.reportHidRPCKeyboardLedState(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
|
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
|
||||||
if currentSession != nil {
|
// Check if keystrokes should be private
|
||||||
currentSession.enqueueKeysDownState(state)
|
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
|
||||||
|
// Report to primary session only
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.enqueueKeysDownState(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Report to all authorized sessions (primary and observers, but not pending)
|
||||||
|
sessionManager.ForEachSession(func(s *Session) {
|
||||||
|
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
|
||||||
|
s.enqueueKeysDownState(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
gadget.SetOnKeepAliveReset(func() {
|
gadget.SetOnKeepAliveReset(func() {
|
||||||
if currentSession != nil {
|
// Reset keep-alive for primary session
|
||||||
currentSession.resetKeepAliveTime()
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
primary.resetKeepAliveTime()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -50,26 +73,82 @@ func initUsbGadget() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcKeyboardReport(modifier byte, keys []byte) error {
|
func (s *Session) rpcKeyboardReport(modifier byte, keys []byte) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return ErrPermissionDeniedKeyboard
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.KeyboardReport(modifier, keys)
|
return gadget.KeyboardReport(modifier, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcKeypressReport(key byte, press bool) error {
|
func (s *Session) rpcKeypressReport(key byte, press bool) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
|
||||||
|
return ErrPermissionDeniedKeyboard
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.KeypressReport(key, press)
|
return gadget.KeypressReport(key, press)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcAbsMouseReport(x int, y int, buttons uint8) error {
|
func (s *Session) rpcAbsMouseReport(x int16, y int16, buttons uint8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.AbsMouseReport(x, y, buttons)
|
return gadget.AbsMouseReport(x, y, buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
|
func (s *Session) rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.RelMouseReport(dx, dy, buttons)
|
return gadget.RelMouseReport(dx, dy, buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcWheelReport(wheelY int8) error {
|
func (s *Session) rpcWheelReport(wheelY int8) error {
|
||||||
|
if s == nil || !s.HasPermission(PermissionMouseInput) {
|
||||||
|
return ErrPermissionDeniedMouse
|
||||||
|
}
|
||||||
|
sessionManager.UpdateLastActive(s.ID)
|
||||||
return gadget.AbsMouseWheelReport(wheelY)
|
return gadget.AbsMouseWheelReport(wheelY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC functions that route to the primary session
|
||||||
|
func rpcKeyboardReport(modifier byte, keys []byte) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcKeyboardReport(modifier, keys)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcKeypressReport(key byte, press bool) error {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
return primary.rpcKeypressReport(key, press)
|
||||||
|
}
|
||||||
|
return ErrNotPrimarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAbsMouseReport(x 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) {
|
func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
|
||||||
return gadget.GetKeyboardState()
|
return gadget.GetKeyboardState()
|
||||||
}
|
}
|
||||||
|
|
@ -89,11 +168,7 @@ func rpcGetUSBState() (state string) {
|
||||||
|
|
||||||
func triggerUSBStateUpdate() {
|
func triggerUSBStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
if currentSession == nil {
|
broadcastJSONRPCEvent("usbState", usbState)
|
||||||
usbLogger.Info().Msg("No active RPC session, skipping USB state update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONRPCEvent("usbState", usbState, currentSession)
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
video.go
2
video.go
|
|
@ -8,7 +8,7 @@ var lastVideoState native.VideoState
|
||||||
|
|
||||||
func triggerVideoStateUpdate() {
|
func triggerVideoStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
writeJSONRPCEvent("videoInputState", lastVideoState, currentSession)
|
broadcastJSONRPCEvent("videoInputState", lastVideoState)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")
|
nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated")
|
||||||
|
|
|
||||||
170
web.go
170
web.go
|
|
@ -35,9 +35,21 @@ var staticFiles embed.FS
|
||||||
|
|
||||||
type WebRTCSessionRequest struct {
|
type WebRTCSessionRequest struct {
|
||||||
Sd string `json:"sd"`
|
Sd string `json:"sd"`
|
||||||
|
SessionId string `json:"sessionId,omitempty"`
|
||||||
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
ICEServers []string `json:"iceServers,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 {
|
type SetPasswordRequest struct {
|
||||||
|
|
@ -158,32 +170,16 @@ func setupRouter() *gin.Engine {
|
||||||
protected := r.Group("/")
|
protected := r.Group("/")
|
||||||
protected.Use(protectedMiddleware())
|
protected.Use(protectedMiddleware())
|
||||||
{
|
{
|
||||||
/*
|
|
||||||
* Legacy WebRTC session endpoint
|
|
||||||
*
|
|
||||||
* This endpoint is maintained for backward compatibility when users upgrade from a version
|
|
||||||
* using the legacy HTTP-based signaling method to the new WebSocket-based signaling method.
|
|
||||||
*
|
|
||||||
* During the upgrade process, when the "Rebooting device after update..." message appears,
|
|
||||||
* the browser still runs the previous JavaScript code which polls this endpoint to establish
|
|
||||||
* a new WebRTC session. Once the session is established, the page will automatically reload
|
|
||||||
* with the updated code.
|
|
||||||
*
|
|
||||||
* Without this endpoint, the stale JavaScript would fail to establish a connection,
|
|
||||||
* causing users to see the "Rebooting device after update..." message indefinitely
|
|
||||||
* until they manually refresh the page, leading to a confusing user experience.
|
|
||||||
*/
|
|
||||||
protected.POST("/webrtc/session", handleWebRTCSession)
|
|
||||||
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
|
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
|
||||||
protected.POST("/cloud/register", handleCloudRegister)
|
protected.POST("/cloud/register", handleCloudRegister)
|
||||||
protected.GET("/cloud/state", handleCloudState)
|
protected.GET("/cloud/state", handleCloudState)
|
||||||
protected.GET("/device", handleDevice)
|
protected.GET("/device", handleDevice)
|
||||||
protected.POST("/auth/logout", handleLogout)
|
protected.POST("/auth/logout", handleLogout)
|
||||||
|
|
||||||
protected.POST("/auth/password-local", handleCreatePassword)
|
protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword)
|
||||||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword)
|
||||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword)
|
||||||
protected.POST("/storage/upload", handleUploadHttp)
|
protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all route for SPA
|
// Catch-all route for SPA
|
||||||
|
|
@ -198,44 +194,6 @@ func setupRouter() *gin.Engine {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: support multiple sessions?
|
|
||||||
var currentSession *Session
|
|
||||||
|
|
||||||
func handleWebRTCSession(c *gin.Context) {
|
|
||||||
var req WebRTCSessionRequest
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := newSession(SessionConfig{})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sd, err := session.ExchangeOffer(req.Sd)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentSession != nil {
|
|
||||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
|
||||||
peerConn := currentSession.peerConnection
|
|
||||||
go func() {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
_ = peerConn.Close()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any ongoing keyboard macro when session changes
|
|
||||||
cancelKeyboardMacro()
|
|
||||||
|
|
||||||
currentSession = session
|
|
||||||
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pingMessage = []byte("ping")
|
pingMessage = []byte("ping")
|
||||||
pongMessage = []byte("pong")
|
pongMessage = []byte("pong")
|
||||||
|
|
@ -244,7 +202,15 @@ var (
|
||||||
func handleLocalWebRTCSignal(c *gin.Context) {
|
func handleLocalWebRTCSignal(c *gin.Context) {
|
||||||
// get the source from the request
|
// get the source from the request
|
||||||
source := c.ClientIP()
|
source := c.ClientIP()
|
||||||
connectionID := uuid.New().String()
|
|
||||||
|
// Try to get existing session ID from cookie for session persistence
|
||||||
|
sessionID, _ := c.Cookie("sessionId")
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.New().String()
|
||||||
|
// Set session ID cookie with same expiry as auth token (7 days)
|
||||||
|
c.SetCookie("sessionId", sessionID, 7*24*60*60, "/", "", false, true)
|
||||||
|
}
|
||||||
|
connectionID := sessionID
|
||||||
|
|
||||||
scopedLogger := websocketLogger.With().
|
scopedLogger := websocketLogger.With().
|
||||||
Str("component", "websocket").
|
Str("component", "websocket").
|
||||||
|
|
@ -276,7 +242,17 @@ func handleLocalWebRTCSignal(c *gin.Context) {
|
||||||
// Now use conn for websocket operations
|
// Now use conn for websocket operations
|
||||||
defer wsCon.Close(websocket.StatusNormalClosure, "")
|
defer wsCon.Close(websocket.StatusNormalClosure, "")
|
||||||
|
|
||||||
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}})
|
// Include session settings in device metadata so client knows requirements upfront
|
||||||
|
sessionSettingsData := gin.H{
|
||||||
|
"deviceVersion": builtAppVersion,
|
||||||
|
}
|
||||||
|
if currentSessionSettings != nil {
|
||||||
|
sessionSettingsData["sessionSettings"] = gin.H{
|
||||||
|
"requireNickname": currentSessionSettings.RequireNickname,
|
||||||
|
"requireApproval": currentSessionSettings.RequireApproval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": sessionSettingsData})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -412,14 +388,17 @@ func handleWebRTCSignalWsMessages(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message")
|
||||||
|
|
||||||
if message.Type == "offer" {
|
if message.Type == "offer" {
|
||||||
l.Info().Msg("new session request received")
|
l.Info().Str("dataRaw", string(message.Data)).Msg("new session request received with raw data")
|
||||||
var req WebRTCSessionRequest
|
var req WebRTCSessionRequest
|
||||||
err = json.Unmarshal(message.Data, &req)
|
err = json.Unmarshal(message.Data, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("unable to parse session request data")
|
l.Warn().Str("error", err.Error()).Str("dataRaw", string(message.Data)).Msg("unable to parse session request data")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request")
|
||||||
|
|
||||||
if req.OidcGoogle != "" {
|
if req.OidcGoogle != "" {
|
||||||
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
|
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
|
||||||
|
|
@ -427,7 +406,7 @@ func handleWebRTCSignalWsMessages(
|
||||||
|
|
||||||
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
||||||
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
||||||
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l)
|
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, connectionID, &l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("error starting new session")
|
l.Warn().Str("error", err.Error()).Msg("error starting new session")
|
||||||
continue
|
continue
|
||||||
|
|
@ -449,14 +428,16 @@ func handleWebRTCSignalWsMessages(
|
||||||
|
|
||||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
|
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
|
||||||
|
|
||||||
if currentSession == nil {
|
// Find the session this ICE candidate belongs to using the connectionID
|
||||||
l.Warn().Msg("no current session, skipping incoming ICE candidate")
|
session := sessionManager.GetSession(connectionID)
|
||||||
|
if session == nil {
|
||||||
|
l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session")
|
l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session")
|
||||||
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
|
if err = session.peerConnection.AddICECandidate(candidate); err != nil {
|
||||||
l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection")
|
l.Warn().Str("error", err.Error()).Str("sessionID", session.ID).Msg("failed to add incoming ICE candidate to peer connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +462,16 @@ func handleLogin(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
config.LocalAuthToken = uuid.New().String()
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set the cookie
|
// Set the cookie
|
||||||
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
||||||
|
|
@ -490,14 +480,10 @@ func handleLogin(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogout(c *gin.Context) {
|
func handleLogout(c *gin.Context) {
|
||||||
config.LocalAuthToken = ""
|
// Only clear the cookies for this session, don't invalidate the token
|
||||||
if err := SaveConfig(); err != nil {
|
// The token should remain valid for other sessions
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the auth cookie
|
|
||||||
c.SetCookie("authToken", "", -1, "/", "", false, true)
|
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"})
|
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) {
|
func sendErrorJsonThenAbort(c *gin.Context, status int, message string) {
|
||||||
c.JSON(status, gin.H{"error": message})
|
c.JSON(status, gin.H{"error": message})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
@ -591,7 +609,7 @@ func RunWebServer() {
|
||||||
|
|
||||||
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
|
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
|
||||||
if err := r.Run(bindAddress); err != nil {
|
if err := r.Run(bindAddress); err != nil {
|
||||||
panic(err)
|
logger.Fatal().Err(err).Msg("failed to start web server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ func runWebSecureServer() {
|
||||||
|
|
||||||
err := server.ListenAndServeTLS("", "")
|
err := server.ListenAndServeTLS("", "")
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
panic(err)
|
websecureLogger.Fatal().Err(err).Msg("failed to start websecure server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
227
webrtc.go
227
webrtc.go
|
|
@ -19,13 +19,39 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Predefined browser string constants for memory efficiency
|
||||||
|
var (
|
||||||
|
BrowserChrome = "chrome"
|
||||||
|
BrowserFirefox = "firefox"
|
||||||
|
BrowserSafari = "safari"
|
||||||
|
BrowserEdge = "edge"
|
||||||
|
BrowserOpera = "opera"
|
||||||
|
BrowserUnknown = "user"
|
||||||
|
)
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Mode SessionMode
|
||||||
|
Source string
|
||||||
|
Identity string
|
||||||
|
Nickname string
|
||||||
|
Browser *string // Pointer to predefined browser string constant for memory efficiency
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActive time.Time
|
||||||
|
LastBroadcast time.Time // Per-session broadcast throttle
|
||||||
|
|
||||||
|
// RPC rate limiting (DoS protection)
|
||||||
|
rpcRateLimitMu sync.Mutex // Protects rate limit fields
|
||||||
|
rpcRateLimit int // Count of RPCs in current window
|
||||||
|
rpcRateLimitWin time.Time // Start of current rate limit window
|
||||||
|
|
||||||
peerConnection *webrtc.PeerConnection
|
peerConnection *webrtc.PeerConnection
|
||||||
VideoTrack *webrtc.TrackLocalStaticSample
|
VideoTrack *webrtc.TrackLocalStaticSample
|
||||||
ControlChannel *webrtc.DataChannel
|
ControlChannel *webrtc.DataChannel
|
||||||
RPCChannel *webrtc.DataChannel
|
RPCChannel *webrtc.DataChannel
|
||||||
HidChannel *webrtc.DataChannel
|
HidChannel *webrtc.DataChannel
|
||||||
shouldUmountVirtualMedia bool
|
shouldUmountVirtualMedia bool
|
||||||
|
flushCandidates func() // Callback to flush buffered ICE candidates
|
||||||
|
|
||||||
rpcQueue chan webrtc.DataChannelMessage
|
rpcQueue chan webrtc.DataChannelMessage
|
||||||
|
|
||||||
|
|
@ -39,6 +65,30 @@ type Session struct {
|
||||||
keysDownStateQueue chan usbgadget.KeysDownState
|
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() {
|
func (s *Session) resetKeepAliveTime() {
|
||||||
s.keepAliveJitterLock.Lock()
|
s.keepAliveJitterLock.Lock()
|
||||||
defer s.keepAliveJitterLock.Unlock()
|
defer s.keepAliveJitterLock.Unlock()
|
||||||
|
|
@ -55,6 +105,7 @@ type SessionConfig struct {
|
||||||
ICEServers []string
|
ICEServers []string
|
||||||
LocalIP string
|
LocalIP string
|
||||||
IsCloud bool
|
IsCloud bool
|
||||||
|
UserAgent string // User agent for browser detection and nickname generation
|
||||||
ws *websocket.Conn
|
ws *websocket.Conn
|
||||||
Logger *zerolog.Logger
|
Logger *zerolog.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -106,9 +157,16 @@ func (s *Session) initQueues() {
|
||||||
|
|
||||||
func (s *Session) handleQueues(index int) {
|
func (s *Session) handleQueues(index int) {
|
||||||
for msg := range s.hidQueue[index] {
|
for msg := range s.hidQueue[index] {
|
||||||
|
// 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)
|
onHidMessage(msg, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keysDownStateQueueSize = 64
|
const keysDownStateQueueSize = 64
|
||||||
|
|
||||||
|
|
@ -218,7 +276,10 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &Session{peerConnection: peerConnection}
|
session := &Session{
|
||||||
|
peerConnection: peerConnection,
|
||||||
|
Browser: extractBrowserFromUserAgent(config.UserAgent),
|
||||||
|
}
|
||||||
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
|
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
|
||||||
session.initQueues()
|
session.initQueues()
|
||||||
session.initKeysDownStateQueue()
|
session.initKeysDownStateQueue()
|
||||||
|
|
@ -226,7 +287,16 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range session.rpcQueue {
|
for msg := range session.rpcQueue {
|
||||||
// TODO: only use goroutine if the task is asynchronous
|
// 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()
|
triggerVideoStateUpdate()
|
||||||
triggerUSBStateUpdate()
|
triggerUSBStateUpdate()
|
||||||
case "terminal":
|
case "terminal":
|
||||||
handleTerminalChannel(d)
|
handleTerminalChannel(d, session)
|
||||||
case "serial":
|
case "serial":
|
||||||
handleSerialChannel(d)
|
handleSerialChannel(d, session)
|
||||||
default:
|
default:
|
||||||
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
|
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
|
||||||
go handleUploadChannel(d)
|
go handleUploadChannel(d)
|
||||||
|
|
@ -297,9 +367,23 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
}()
|
}()
|
||||||
var isConnected bool
|
var isConnected bool
|
||||||
|
|
||||||
|
// Buffer to hold ICE candidates until answer is sent
|
||||||
|
var candidateBuffer []webrtc.ICECandidateInit
|
||||||
|
var candidateBufferMutex sync.Mutex
|
||||||
|
var answerSent bool
|
||||||
|
|
||||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||||
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
|
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
|
||||||
if candidate != nil {
|
if candidate != nil {
|
||||||
|
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()})
|
err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
|
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
|
||||||
|
|
@ -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) {
|
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||||
scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed")
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Str("connectionState", connectionState.String()).
|
||||||
|
Msg("ICE Connection State has changed")
|
||||||
|
|
||||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||||
if !isConnected {
|
if !isConnected {
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
|
@ -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 {
|
if connectionState == webrtc.ICEConnectionStateFailed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection")
|
scopedLogger.Info().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Msg("ICE Connection State is failed, closing peerConnection and cleaning up")
|
||||||
|
cleanupSession("ice-failed")
|
||||||
_ = peerConnection.Close()
|
_ = peerConnection.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectionState == webrtc.ICEConnectionStateClosed {
|
if connectionState == webrtc.ICEConnectionStateClosed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
scopedLogger.Info().
|
||||||
if session == currentSession {
|
Str("sessionID", session.ID).
|
||||||
// Cancel any ongoing keyboard report multi when session closes
|
Msg("ICE Connection State is closed, cleaning up")
|
||||||
cancelKeyboardMacro()
|
cleanupSession("ice-closed")
|
||||||
currentSession = nil
|
|
||||||
}
|
|
||||||
// Stop RPC processor
|
|
||||||
if session.rpcQueue != nil {
|
|
||||||
close(session.rpcQueue)
|
|
||||||
session.rpcQueue = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop HID RPC processor
|
|
||||||
for i := 0; i < len(session.hidQueue); i++ {
|
|
||||||
close(session.hidQueue[i])
|
|
||||||
session.hidQueue[i] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
close(session.keysDownStateQueue)
|
|
||||||
session.keysDownStateQueue = nil
|
|
||||||
|
|
||||||
if session.shouldUmountVirtualMedia {
|
|
||||||
if err := rpcUnmountImage(); err != nil {
|
|
||||||
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isConnected {
|
|
||||||
isConnected = false
|
|
||||||
actionSessions--
|
|
||||||
onActiveSessionsChanged()
|
|
||||||
if actionSessions == 0 {
|
|
||||||
onLastSessionDisconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return session, nil
|
return session, nil
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue