Compare commits

...

27 Commits

Author SHA1 Message Date
Alex 4bb09495bc
Merge 8d51aaa8eb into 403141c96a 2025-10-15 10:04:33 +02:00
Alex P 8d51aaa8eb fix: prevent session timeout when jiggler is active
The jiggler sends keep-alive packets every 50ms to prevent keyboard
auto-release, but wasn't updating the session's LastActive timestamp.
This caused the backend to timeout and demote the primary session after
5 minutes (default primaryTimeout), even with active jiggler.

Primary fix:
- Add UpdateLastActive call to handleHidRPCKeepressKeepAlive() in hidrpc.go
- Ensures jiggler packets prevent session timeout

Defensive enhancement:
- Add WebSocket fallback for emergency promotion signals in session_manager.go
- Store WebSocket reference in Session struct (webrtc.go)
- Handle connectionModeChanged via WebSocket in devices.$id.tsx
- Provides reliable signaling when WebRTC data channel is stale
2025-10-13 13:10:12 +03:00
Alex P 5a0100478b feat: add configurable max sessions and observer cleanup timeout
Add two new configurable session settings to improve multi-session management:

1. Maximum Concurrent Sessions (1-20, default: 10)
   - Controls the maximum number of simultaneous connections
   - Configurable via settings UI with validation
   - Applied in session manager during session creation

2. Observer Cleanup Timeout (30-600 seconds, default: 120)
   - Automatically removes inactive observer sessions with closed RPC channels
   - Prevents accumulation of zombie observer sessions
   - Runs during periodic cleanup checks
   - Configurable timeout displayed in minutes in UI

Backend changes:
- Add MaxSessions and ObserverTimeout fields to SessionSettings struct
- Update setSessionSettings RPC handler to persist new settings
- Implement observer cleanup logic in cleanupInactiveSessions
- Apply maxSessions limit in NewSessionManager with proper fallback chain

Frontend changes:
- Add numeric input controls for both settings in multi-session settings page
- Include validation and user-friendly error messages
- Display friendly units (sessions, seconds/minutes)
- Maintain consistent styling with existing settings

Also includes defensive nil checks in writeJSONRPCEvent to prevent
"No HDMI Signal" errors when RPC channels close during reconnection.
2025-10-11 14:26:05 +03:00
Alex P 16509188b0 fix: use permission-based guards for RPC initialization calls
Replace UI-state based guards (showNicknameModal, currentMode checks) with
actual permission checks from PermissionsProvider. This ensures RPC calls
are only made when sessions have the required permissions.

Changes:
- getVideoState now checks for Permission.VIDEO_VIEW
- getKeyboardLedState checks for Permission.KEYBOARD_INPUT
- getKeyDownState checks for Permission.KEYBOARD_INPUT
- All checks wait for permissions to load (isLoadingPermissions)

This prevents "Permission denied" errors that occurred when RPC calls
were made before sessions received proper permissions.
2025-10-11 00:31:20 +03:00
Alex P 554b43fae9 Cleanup: Remove accidentally removed file 2025-10-11 00:23:09 +03:00
Alex P f27c2f4eb2 fix: prevent RPC calls before session approval
Issue:
- RPC calls (getVideoState, getKeyboardLedState, getKeyDownState) were being made
  immediately when RPC data channel opened, before permissions were granted
- This caused "Permission denied" errors in console for pending/queued sessions
- Sessions waiting for nickname or approval were triggering permission errors

Solution:
- Added currentMode checks to guard RPC initialization calls
- Only make RPC calls when session is in "primary" or "observer" mode
- Skip RPC calls for "pending" or "queued" sessions

Result: No more permission errors before session approval
2025-10-11 00:17:49 +03:00
Alex P 335c6ee35e refactor: centralize permissions with context provider and remove redundant code
Improvements:
- Centralized permission state management in PermissionsProvider
  - Eliminates duplicate RPC calls across components
  - Single source of truth for permission state
  - Automatic HID re-initialization on permission changes
- Split exports into separate files for React Fast Refresh compliance
  - Created types/permissions.ts for Permission enum
  - Created hooks/usePermissions.ts for the hook with safe defaults
  - Created contexts/PermissionsContext.ts for context definition
  - Updated PermissionsProvider.tsx to only export the provider component
- Removed redundant getSessionSettings RPC call (settings already in WebSocket/WebRTC messages)
- Added connectionModeChanged event handler for seamless emergency promotions
- Fixed approval dialog race condition by checking isLoadingPermissions
- Removed all redundant comments and code for leaner implementation
- Updated imports across 10+ component files

Result: Zero ESLint warnings, cleaner architecture, no duplicate RPC calls, all functionality preserved
2025-10-11 00:11:20 +03:00
Alex P f9e190f8b9 fix: prevent multiple getPermissions RPC calls on page load
The getPermissions useEffect had send and pollPermissions in its dependency
array. Since send gets recreated when rpcDataChannel changes, this caused
multiple getPermissions RPC calls (5 observed) on page load.

Fix:
- Add rpcDataChannel readiness check to prevent calls before channel is open
- Remove send and pollPermissions from dependency array
- Keep only currentMode and rpcDataChannel.readyState as dependencies

This ensures getPermissions is called only when:
1. The RPC channel becomes ready (readyState changes to "open")
2. The session mode changes (observer <-> primary)

Eliminates duplicate RPC calls while maintaining correct behavior for
mode changes and initial connection.
2025-10-10 22:23:25 +03:00
Alex P 00e6edbfa8 fix: prevent infinite getLocalVersion RPC calls on refresh
The getLocalVersion useEffect had getLocalVersion and hasPermission in
its dependency array. Since these functions are recreated on every render,
this caused an infinite loop of RPC calls when refreshing the primary
session, resulting in 100+ identical getLocalVersion requests.

Fix: Remove function references from dependency array, only keep appVersion
which is the actual data dependency. The effect now only runs once when
appVersion changes from null to a value.

This is the same pattern as the previous fix for getVideoState,
getKeyboardLedState, and getKeyDownState.
2025-10-10 22:15:44 +03:00
Alex P f90c255656 fix: prevent unnecessary RPC calls for pending sessions and increase rate limit
Changes:
- Add permission checks before making getVideoState, getKeyboardLedState,
  and getKeyDownState RPC calls to prevent rejected requests for sessions
  without VIDEO_VIEW permission
- Fix infinite loop issue by excluding hasPermission from useEffect
  dependency arrays (functions recreated on render cause infinite loops)
- Increase RPC rate limit from 100 to 500 per second to support 10+
  concurrent sessions with broadcasts and state updates

This eliminates console spam from permission denied errors and log spam
from continuous RPC calls, while improving multi-session performance.
2025-10-10 22:10:33 +03:00
Alex P 821675cd21 security: fix critical race conditions and add validation to session management
This commit addresses multiple CRITICAL and HIGH severity security issues
identified during the multi-session implementation review.

CRITICAL Fixes:
- Fix race condition in session approval handlers (jsonrpc.go)
  Previously approveNewSession and denyNewSession directly mutated
  session.Mode without holding the SessionManager.mu lock, potentially
  causing data corruption during concurrent access.

- Add validation to ApprovePrimaryRequest (session_manager.go:795-810)
  Now verifies that requester session exists and is in Queued mode
  before approving transfer, preventing invalid state transitions.

- Close dual-primary window during reconnection (session_manager.go:208)
  Added explicit primaryExists check to prevent brief window where two
  sessions could both be primary during reconnection.

HIGH Priority Fixes:
- Add nickname uniqueness validation (session_manager.go:152-159)
  Prevents multiple sessions from having the same nickname, both in
  AddSession and updateSessionNickname RPC handler.

Code Quality:
- Remove debug scaffolding from cloud.go (lines 515-520, 530)
  Cleaned up temporary debug logs that are no longer needed.

Thread Safety:
- Add centralized ApproveSession() method (session_manager.go:870-890)
- Add centralized DenySession() method (session_manager.go:894-912)
  Both methods properly acquire locks and validate session state.

- Update RPC handlers to use thread-safe methods
  approveNewSession and denyNewSession now call sessionManager methods
  instead of direct session mutation.

All changes have been verified with linters (golangci-lint: 0 issues).
2025-10-10 20:04:44 +03:00
Alex P 825299257d fix: correct grace period protection during primary reconnection
The session manager had backwards logic that prevented sessions from
restoring their primary status when reconnecting within the grace period.
This caused browser refreshes to demote primary sessions to observers.

Changes:
- Fix conditional in AddSession to allow primary restoration within grace
- Remove excessive debug logging throughout session manager
- Clean up unused decrActiveSessions function
- Remove unnecessary leading newline in NewSessionManager
- Update lastPrimaryID handling to support WebRTC reconnections
- Preserve grace periods during transfers to allow browser refreshes

The fix ensures that when a primary session refreshes their browser:
1. RemoveSession adds a grace period entry
2. New connection checks wasWithinGracePeriod and wasPreviouslyPrimary
3. Session correctly reclaims primary status

Blacklist system prevents demoted sessions from immediate re-promotion
while grace periods allow legitimate reconnections.
2025-10-10 19:33:49 +03:00
Alex P 309126bef6 [WIP] Bugfixes: session promotion 2025-10-10 10:16:21 +03:00
Alex P 8dbd98b4f0 Merge branch 'dev' into feat/multisession-support 2025-10-10 00:23:50 +03:00
Alex P ce1cbe1944 fix: move nil check before accessing session.ID to satisfy staticcheck 2025-10-10 00:05:08 +03:00
Alex P c8b456bf6a fix: handle intentional logout to trigger immediate observer promotion
When a user explicitly logs out via the logout button, the session should
be removed immediately without grace period, allowing observers to be
promoted right away instead of waiting for the grace period to expire.

Changes:
- Close WebRTC connection immediately on logout
- Clear grace period marker for intentional logout detection
- Add logging to track logout vs disconnect differentiation

This complements the accidental disconnect handling which uses grace period.
2025-10-09 12:56:57 +03:00
Alex P 57f4be2846 fix: clear transfer blacklist on primary disconnect to enable grace period promotion
When a primary session disconnects accidentally (not intentional logout), the
60-second transfer blacklist from previous role transfers was blocking observer
sessions from being promoted after the grace period expires (~10s).

The blacklist is intended to prevent immediate re-promotion during manual
transfers (user-initiated), but should not interfere with emergency promotion
after accidental disconnects (system-initiated).

Changes:
- Clear all transfer blacklist entries when primary enters grace period
- Add logging to track blacklist clearing for debugging
- Preserve blacklist during intentional logout to maintain manual transfer protection

This ensures observers are promoted after grace period (~10s) instead of
waiting for blacklist expiration (~40-60s).
2025-10-09 12:55:25 +03:00
Alex P b388bc3c62 fix: reduce observer promotion delay from ~40s to ~11s
1. Terminal access permission check:
   - Add Permission.TERMINAL_ACCESS check to Web Terminal button
   - Prevents observer sessions from accessing terminal

2. Immediate websocket cleanup:
   - Close peer connection immediately when websocket errors
   - Previously waited 24+ seconds for ICE to transition from disconnected to failed
   - Now triggers session cleanup immediately on tab close

3. Immediate grace period validation:
   - Trigger validateSinglePrimary() immediately when grace period expires
   - Previously waited up to 10 seconds for next periodic validation
   - Eliminates unnecessary delay in observer promotion

Timeline improvement:
Before: Tab close → 6s (ICE disconnect) → 24s (ICE fail) → RemoveSession → 10s grace → up to 10s validation = ~50s total
After: Tab close → immediate peerConnection.Close() → immediate RemoveSession → 10s grace → immediate validation = ~11s total
2025-10-09 11:39:00 +03:00
Alex P 7901677551 fix: increase RPC rate limit from 20 to 100 per second
The previous limit of 20 RPC/second per session was too aggressive for
multi-session scenarios. During normal operation with multiple sessions,
legitimate RPC calls would frequently hit the rate limit, especially
during page refreshes or reconnections when sessions make bursts of calls
like getSessions, getPermissions, getLocalVersion, and getVideoState.

Increased the limit to 100 RPC/second per session, which still provides
DoS protection while accommodating legitimate multi-session usage patterns.
2025-10-09 11:19:10 +03:00
Alex P ba8caf3448 debug: add detailed logging to trace session addition flow
Add comprehensive logging to identify why sessions fail to be added to
the session manager:
- Log entry/exit points in AddSession
- Track reconnection path execution
- Log max sessions limit checks
- Trace AddSession call and return in handleSessionRequest

This will help diagnose why sessions get stuck at ICE checking state
without being properly registered in the session manager.
2025-10-09 10:58:06 +03:00
Alex P 541d2bd77d fix: correct grace period protection during primary reconnection
- Remove broken bypass logic that caused immediate observer promotion on refresh
- Add session map debugging logs to validateSinglePrimary
- Ensure grace period properly blocks auto-promotion until expiration
2025-10-08 23:58:41 +03:00
Alex P f9ebd6ac2f feat: add strict observer-to-primary promotion controls and immediate logout promotion
Observer-to-primary promotion protections:
- Block auto-promotion during active primary grace periods
- Prevent creating multiple primary sessions simultaneously
- Validate transfer source is actual current primary
- Check for duplicate primaries before promotion

Immediate promotion on logout:
- Trigger validateSinglePrimary() immediately when primary disconnects
- Smart grace period bypass: allow promotion within 2 seconds of disconnect
- Provides instant promotion on logout while protecting against network blips

Enhanced validation and logging:
- Log session additions/removals with counts
- Display session IDs in validation logs for debugging
- Track grace period timing for smart bypass decisions
2025-10-08 23:44:10 +03:00
Alex P ffc4a2af21 fix: prevent getLocalVersion call for sessions without video permission
Sessions in pending mode do not have PermissionVideoView and should not
attempt to call getLocalVersion RPC method. Add permission check before
calling getLocalVersion to prevent unnecessary permission denied errors.
2025-10-08 21:42:59 +03:00
Alex P a1548fe5b1 feat: improve session approval workflow with re-request and rejection limits
Backend improvements:
- Keep denied sessions alive in pending mode instead of removing them
- Add requestSessionApproval RPC method for re-requesting access
- Fix security issue: preserve pending mode on reconnection for denied sessions
- Add MaxRejectionAttempts field to SessionSettings (default: 3, configurable 1-10)

Frontend improvements:
- Change "Try Again" button to "Request Access Again" that re-requests approval
- Add rejection counter with configurable maximum attempts
- Hide modal after max rejections; session stays pending in SessionPopover
- Add "Dismiss" button for primary to hide approval requests without deciding
- Add MaxRejectionAttempts control in multi-session settings page
- Reset rejection count when session is approved

This improves the user experience by allowing denied users to retry without
page reloads, while preventing spam with configurable rejection limits.
2025-10-08 21:37:02 +03:00
Alex P b0494e8eef security: prevent video access for pending/denied sessions
CRITICAL SECURITY FIX: Pending sessions (awaiting approval) were granted
video.view permission, allowing denied sessions to see video when they
reconnected.

**Vulnerability:**
1. Session requests access and enters pending mode
2. Primary session denies the request
3. Denied session clicks "Try Again" and reconnects
4. New session enters pending mode but has video.view permission
5. User can see video stream despite being denied

**Fix:**
Remove PermissionVideoView from SessionModePending. Pending sessions now
have NO permissions until explicitly approved by the primary session.

This ensures:
- Denied sessions cannot access video on reconnection
- Only approved sessions (observer/queued/primary) can view video
- CanReceiveVideo() properly blocks video frames for pending sessions
2025-10-08 20:26:18 +03:00
Alex P b322255684 fix: resolve all Go and TypeScript linting issues
Address all linting warnings and errors in both backend and frontend code:

**Go (golangci-lint):**
- Add error checking for ignored return values (errcheck)
- Remove unused RPC functions (unused)
- Fix import formatting (goimports)

**TypeScript/React (eslint):**
- Replace all 'any' and 'Function' types with proper type definitions
- Add RpcSendFunction type for consistent JSON-RPC callback signatures
- Fix React Hook exhaustive-deps warnings by adding missing dependencies
- Wrap functions in useCallback where needed to stabilize dependencies
- Remove unused variables and imports
- Remove empty code blocks
- Suppress exhaustive-deps warnings where intentional (with comments)

All linting now passes with 0 errors and 0 warnings.
2025-10-08 20:15:45 +03:00
Alex P cd70efb83f 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(-)
2025-10-08 18:52:45 +03:00
53 changed files with 6439 additions and 481 deletions

View File

@ -197,6 +197,20 @@ func wsResetMetrics(established bool, sourceType string, source string) {
}
func handleCloudRegister(c *gin.Context) {
sessionID, _ := c.Cookie("sessionId")
authToken, _ := c.Cookie("authToken")
if sessionID != "" && authToken != "" && authToken == config.LocalAuthToken {
session := sessionManager.GetSession(sessionID)
if session != nil && !session.HasPermission(PermissionSettingsWrite) {
c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"})
return
}
} else if sessionID != "" {
c.JSON(401, gin.H{"error": "Authentication required"})
return
}
var req CloudRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -426,8 +440,15 @@ func handleSessionRequest(
req WebRTCSessionRequest,
isCloudConnection bool,
source string,
connectionID string,
scopedLogger *zerolog.Logger,
) error {
) (returnErr error) {
defer func() {
if r := recover(); r != nil {
websocketLogger.Error().Interface("panic", r).Msg("PANIC in handleSessionRequest")
returnErr = fmt.Errorf("panic: %v", r)
}
}()
var sourceType string
if isCloudConnection {
sourceType = "cloud"
@ -453,6 +474,7 @@ func handleSessionRequest(
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
UserAgent: req.UserAgent,
Logger: scopedLogger,
})
if err != nil {
@ -462,26 +484,73 @@ func handleSessionRequest(
sd, err := session.ExchangeOffer(req.Sd)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to exchange offer")
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
}
if currentSession != nil {
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection
go func() {
time.Sleep(1 * time.Second)
_ = peerConn.Close()
}()
session.Source = source
if isCloudConnection && req.OidcGoogle != "" {
session.Identity = config.GoogleIdentity
// Use client-provided sessionId for reconnection, otherwise generate new one
// This enables multi-tab support while preserving reconnection on refresh
if req.SessionId != "" {
session.ID = req.SessionId
scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID")
} else {
session.ID = connectionID
scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established")
}
} else {
session.ID = connectionID
scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established")
}
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
if sessionManager == nil {
scopedLogger.Error().Msg("sessionManager is nil")
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
return fmt.Errorf("session manager not initialized")
}
// Cancel any ongoing keyboard macro when session changes
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
}
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()
}
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
requireNickname := false
requireApproval := false
if currentSessionSettings != nil {
requireNickname = currentSessionSettings.RequireNickname
requireApproval = currentSessionSettings.RequireApproval
}
err = wsjson.Write(context.Background(), c, gin.H{
"type": "answer",
"data": sd,
"sessionId": session.ID,
"mode": session.Mode,
"nickname": session.Nickname,
"requireNickname": requireNickname,
"requireApproval": requireApproval,
})
if err != nil {
return err
}
if session.flushCandidates != nil {
session.flushCandidates()
}
return nil
}

View File

@ -77,11 +77,21 @@ func (m *KeyboardMacro) Validate() error {
return nil
}
// MultiSessionConfig defines settings for multi-session support
type MultiSessionConfig struct {
Enabled bool `json:"enabled"`
MaxSessions int `json:"max_sessions"`
PrimaryTimeout int `json:"primary_timeout_seconds"`
AllowCloudOverride bool `json:"allow_cloud_override"`
RequireAuthTransfer bool `json:"require_auth_transfer"`
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
MultiSession *MultiSessionConfig `json:"multi_session"`
JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
@ -104,6 +114,7 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
SessionSettings *SessionSettings `json:"session_settings"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
}
@ -133,12 +144,26 @@ var defaultConfig = &Config{
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
MultiSession: &MultiSessionConfig{
Enabled: true, // Enable by default for new features
MaxSessions: 10, // Reasonable default
PrimaryTimeout: 300, // 5 minutes
AllowCloudOverride: true, // Cloud sessions can take control
RequireAuthTransfer: false, // Don't require auth by default
},
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
SessionSettings: &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
},
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{

11
datachannel_helpers.go Normal file
View File

@ -0,0 +1,11 @@
package kvm
import "github.com/pion/webrtc/v4"
func handlePermissionDeniedChannel(d *webrtc.DataChannel, message string) {
d.OnOpen(func() {
_ = d.SendText(message + "\r\n")
d.Close()
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {})
}

10
errors.go Normal file
View File

@ -0,0 +1,10 @@
package kvm
import "errors"
var (
ErrPermissionDeniedKeyboard = errors.New("permission denied: keyboard input")
ErrPermissionDeniedMouse = errors.New("permission denied: mouse input")
ErrNotPrimarySession = errors.New("operation requires primary session")
ErrSessionNotFound = errors.New("session not found")
)

View File

@ -27,8 +27,14 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
if !session.HasPermission(PermissionKeyboardInput) {
return
}
rpcErr = handleHidRPCKeyboardInput(message)
case hidrpc.TypeKeyboardMacroReport:
if !session.HasPermission(PermissionPaste) {
return
}
keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
@ -36,18 +42,30 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
case hidrpc.TypeCancelKeyboardMacroReport:
if !session.HasPermission(PermissionPaste) {
return
}
rpcCancelKeyboardMacro()
return
case hidrpc.TypeKeypressKeepAliveReport:
if !session.HasPermission(PermissionKeyboardInput) {
return
}
rpcErr = handleHidRPCKeypressKeepAlive(session)
case hidrpc.TypePointerReport:
if !session.HasPermission(PermissionMouseInput) {
return
}
pointerReport, err := message.PointerReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get pointer report")
return
}
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
rpcErr = rpcAbsMouseReport(int16(pointerReport.X), int16(pointerReport.Y), pointerReport.Button)
case hidrpc.TypeMouseReport:
if !session.HasPermission(PermissionMouseInput) {
return
}
mouseReport, err := message.MouseReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get mouse report")
@ -116,14 +134,15 @@ const baseExtension = expectedRate + maxLateness // 100ms extension on perfect t
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
func handleHidRPCKeypressKeepAlive(session *Session) error {
// Update LastActive to prevent session timeout (jiggler sends every 50ms)
sessionManager.UpdateLastActive(session.ID)
session.keepAliveJitterLock.Lock()
defer session.keepAliveJitterLock.Unlock()
now := time.Now()
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
// This prevents “zombie” keepalives from reviving a key that should already be released.
// Staleness guard: discard ancient packets after network stall/machine sleep
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
return nil
}

View File

@ -0,0 +1,306 @@
package session
import "fmt"
// Permission represents a specific action that can be performed
type Permission string
const (
// Video/Display permissions
PermissionVideoView Permission = "video.view"
// Input permissions
PermissionKeyboardInput Permission = "keyboard.input"
PermissionMouseInput Permission = "mouse.input"
PermissionPaste Permission = "clipboard.paste"
// Session management permissions
PermissionSessionTransfer Permission = "session.transfer"
PermissionSessionApprove Permission = "session.approve"
PermissionSessionKick Permission = "session.kick"
PermissionSessionRequestPrimary Permission = "session.request_primary"
PermissionSessionReleasePrimary Permission = "session.release_primary"
PermissionSessionManage Permission = "session.manage"
// Power/USB control permissions
PermissionPowerControl Permission = "power.control"
PermissionUSBControl Permission = "usb.control"
// Mount/Media permissions
PermissionMountMedia Permission = "mount.media"
PermissionUnmountMedia Permission = "mount.unmedia"
PermissionMountList Permission = "mount.list"
// Extension permissions
PermissionExtensionManage Permission = "extension.manage"
// Terminal/Serial permissions
PermissionTerminalAccess Permission = "terminal.access"
PermissionSerialAccess Permission = "serial.access"
PermissionExtensionATX Permission = "extension.atx"
PermissionExtensionDC Permission = "extension.dc"
PermissionExtensionSerial Permission = "extension.serial"
PermissionExtensionWOL Permission = "extension.wol"
// Settings permissions
PermissionSettingsRead Permission = "settings.read"
PermissionSettingsWrite Permission = "settings.write"
PermissionSettingsAccess Permission = "settings.access" // Access control settings
// System permissions
PermissionSystemReboot Permission = "system.reboot"
PermissionSystemUpdate Permission = "system.update"
PermissionSystemNetwork Permission = "system.network"
)
// PermissionSet represents a set of permissions
type PermissionSet map[Permission]bool
// RolePermissions defines permissions for each session mode
var RolePermissions = map[SessionMode]PermissionSet{
SessionModePrimary: {
// Primary has all permissions
PermissionVideoView: true,
PermissionKeyboardInput: true,
PermissionMouseInput: true,
PermissionPaste: true,
PermissionSessionTransfer: true,
PermissionSessionApprove: true,
PermissionSessionKick: true,
PermissionSessionReleasePrimary: true,
PermissionMountMedia: true,
PermissionUnmountMedia: true,
PermissionMountList: true,
PermissionExtensionManage: true,
PermissionExtensionATX: true,
PermissionExtensionDC: true,
PermissionExtensionSerial: true,
PermissionExtensionWOL: true,
PermissionSettingsRead: true,
PermissionSettingsWrite: true,
PermissionSettingsAccess: true, // Only primary can access settings UI
PermissionSystemReboot: true,
PermissionSystemUpdate: true,
PermissionSystemNetwork: true,
PermissionTerminalAccess: true,
PermissionSerialAccess: true,
PermissionPowerControl: true,
PermissionUSBControl: true,
PermissionSessionManage: true,
PermissionSessionRequestPrimary: false, // Primary doesn't need to request
},
SessionModeObserver: {
// Observers can only view
PermissionVideoView: true,
PermissionSessionRequestPrimary: true,
PermissionMountList: true, // Can see what's mounted but not mount/unmount
},
SessionModeQueued: {
// Queued sessions can view and request primary
PermissionVideoView: true,
PermissionSessionRequestPrimary: true,
},
SessionModePending: {
// Pending sessions have NO permissions until approved
// This prevents unauthorized video access
},
}
// CheckPermission checks if a session mode has a specific permission
func CheckPermission(mode SessionMode, perm Permission) bool {
permissions, exists := RolePermissions[mode]
if !exists {
return false
}
return permissions[perm]
}
// GetPermissionsForMode returns all permissions for a session mode
func GetPermissionsForMode(mode SessionMode) PermissionSet {
permissions, exists := RolePermissions[mode]
if !exists {
return PermissionSet{}
}
// Return a copy to prevent modification
result := make(PermissionSet)
for k, v := range permissions {
result[k] = v
}
return result
}
// RequirePermissionForMode is a middleware-like function for RPC handlers
func RequirePermissionForMode(mode SessionMode, perm Permission) error {
if !CheckPermission(mode, perm) {
return fmt.Errorf("permission denied: %s", perm)
}
return nil
}
// GetPermissionsResponse is the response structure for getPermissions RPC
type GetPermissionsResponse struct {
Mode string `json:"mode"`
Permissions map[string]bool `json:"permissions"`
}
// MethodPermissions maps RPC methods to required permissions
var MethodPermissions = map[string]Permission{
// Power/hardware control
"setATXPowerAction": PermissionPowerControl,
"setDCPowerState": PermissionPowerControl,
"setDCRestoreState": PermissionPowerControl,
// USB device control
"setUsbDeviceState": PermissionUSBControl,
"setUsbDevices": PermissionUSBControl,
// Mount operations
"mountUsb": PermissionMountMedia,
"unmountUsb": PermissionMountMedia,
"mountBuiltInImage": PermissionMountMedia,
"rpcMountBuiltInImage": PermissionMountMedia,
"unmountImage": PermissionMountMedia,
"mountWithHTTP": PermissionMountMedia,
"mountWithStorage": PermissionMountMedia,
"checkMountUrl": PermissionMountMedia,
"startStorageFileUpload": PermissionMountMedia,
"deleteStorageFile": PermissionMountMedia,
// Settings operations
"setDevModeState": PermissionSettingsWrite,
"setDevChannelState": PermissionSettingsWrite,
"setAutoUpdateState": PermissionSettingsWrite,
"tryUpdate": PermissionSettingsWrite,
"reboot": PermissionSettingsWrite,
"resetConfig": PermissionSettingsWrite,
"setNetworkSettings": PermissionSettingsWrite,
"setLocalLoopbackOnly": PermissionSettingsWrite,
"renewDHCPLease": PermissionSettingsWrite,
"setSSHKeyState": PermissionSettingsWrite,
"setTLSState": PermissionSettingsWrite,
"setVideoBandwidth": PermissionSettingsWrite,
"setVideoFramerate": PermissionSettingsWrite,
"setVideoResolution": PermissionSettingsWrite,
"setVideoEncoderQuality": PermissionSettingsWrite,
"setVideoSignal": PermissionSettingsWrite,
"setSerialBitrate": PermissionSettingsWrite,
"setSerialSettings": PermissionSettingsWrite,
"setSessionSettings": PermissionSessionManage,
"updateSessionSettings": PermissionSessionManage,
// Display settings
"setEDID": PermissionSettingsWrite,
"setStreamQualityFactor": PermissionSettingsWrite,
"setDisplayRotation": PermissionSettingsWrite,
"setBacklightSettings": PermissionSettingsWrite,
// USB/HID settings
"setUsbEmulationState": PermissionSettingsWrite,
"setUsbConfig": PermissionSettingsWrite,
"setKeyboardLayout": PermissionSettingsWrite,
"setJigglerState": PermissionSettingsWrite,
"setJigglerConfig": PermissionSettingsWrite,
"setMassStorageMode": PermissionSettingsWrite,
"setKeyboardMacros": PermissionSettingsWrite,
"setWakeOnLanDevices": PermissionSettingsWrite,
// Cloud settings
"setCloudUrl": PermissionSettingsWrite,
"deregisterDevice": PermissionSettingsWrite,
// Active extension control
"setActiveExtension": PermissionExtensionManage,
// Input operations (already handled in other places but for consistency)
"keyboardReport": PermissionKeyboardInput,
"keypressReport": PermissionKeyboardInput,
"absMouseReport": PermissionMouseInput,
"relMouseReport": PermissionMouseInput,
"wheelReport": PermissionMouseInput,
"executeKeyboardMacro": PermissionPaste,
"cancelKeyboardMacro": PermissionPaste,
// Session operations
"approveNewSession": PermissionSessionApprove,
"denyNewSession": PermissionSessionApprove,
"transferSession": PermissionSessionTransfer,
"transferPrimary": PermissionSessionTransfer,
"requestPrimary": PermissionSessionRequestPrimary,
"releasePrimary": PermissionSessionReleasePrimary,
// Extension operations
"activateExtension": PermissionExtensionManage,
"deactivateExtension": PermissionExtensionManage,
"sendWOLMagicPacket": PermissionExtensionWOL,
// Read operations - require appropriate read permissions
"getSessionSettings": PermissionSettingsRead,
"getSessionConfig": PermissionSettingsRead,
"getSessionData": PermissionVideoView,
"getNetworkSettings": PermissionSettingsRead,
"getSerialSettings": PermissionSettingsRead,
"getBacklightSettings": PermissionSettingsRead,
"getDisplayRotation": PermissionSettingsRead,
"getEDID": PermissionSettingsRead,
"get_edid": PermissionSettingsRead,
"getKeyboardLayout": PermissionSettingsRead,
"getJigglerConfig": PermissionSettingsRead,
"getJigglerState": PermissionSettingsRead,
"getStreamQualityFactor": PermissionSettingsRead,
"getVideoSettings": PermissionSettingsRead,
"getVideoBandwidth": PermissionSettingsRead,
"getVideoFramerate": PermissionSettingsRead,
"getVideoResolution": PermissionSettingsRead,
"getVideoEncoderQuality": PermissionSettingsRead,
"getVideoSignal": PermissionSettingsRead,
"getSerialBitrate": PermissionSettingsRead,
"getDevModeState": PermissionSettingsRead,
"getDevChannelState": PermissionSettingsRead,
"getAutoUpdateState": PermissionSettingsRead,
"getLocalLoopbackOnly": PermissionSettingsRead,
"getSSHKeyState": PermissionSettingsRead,
"getTLSState": PermissionSettingsRead,
"getCloudUrl": PermissionSettingsRead,
"getCloudState": PermissionSettingsRead,
"getNetworkState": PermissionSettingsRead,
// Mount/media read operations
"getMassStorageMode": PermissionMountList,
"getUsbState": PermissionMountList,
"getUSBState": PermissionMountList,
"listStorageFiles": PermissionMountList,
"getStorageSpace": PermissionMountList,
// Extension read operations
"getActiveExtension": PermissionSettingsRead,
// Power state reads
"getATXState": PermissionSettingsRead,
"getDCPowerState": PermissionSettingsRead,
"getDCRestoreState": PermissionSettingsRead,
// Device info reads (these should be accessible to all)
"getDeviceID": PermissionVideoView,
"getLocalVersion": PermissionVideoView,
"getVideoState": PermissionVideoView,
"getKeyboardLedState": PermissionVideoView,
"getKeyDownState": PermissionVideoView,
"ping": PermissionVideoView,
"getTimezones": PermissionVideoView,
"getSessions": PermissionVideoView,
"getUpdateStatus": PermissionSettingsRead,
"isUpdatePending": PermissionSettingsRead,
"getUsbEmulationState": PermissionSettingsRead,
"getUsbConfig": PermissionSettingsRead,
"getUsbDevices": PermissionSettingsRead,
"getKeyboardMacros": PermissionSettingsRead,
"getWakeOnLanDevices": PermissionSettingsRead,
"getVirtualMediaState": PermissionMountList,
}
// GetMethodPermission returns the required permission for an RPC method
func GetMethodPermission(method string) (Permission, bool) {
perm, exists := MethodPermissions[method]
return perm, exists
}

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

@ -0,0 +1,11 @@
package session
// SessionMode represents the role/mode of a session
type SessionMode string
const (
SessionModePrimary SessionMode = "primary"
SessionModeObserver SessionMode = "observer"
SessionModeQueued SessionMode = "queued"
SessionModePending SessionMode = "pending"
)

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strconv"
"sync"
"time"
@ -23,6 +24,14 @@ import (
"github.com/jetkvm/kvm/internal/utils"
)
// nicknameRegex defines the valid pattern for nicknames (matching frontend validation)
var nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.@]+$`)
// isValidNickname checks if a nickname contains only valid characters
func isValidNickname(nickname string) bool {
return nicknameRegex.MatchString(nickname)
}
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
@ -54,11 +63,16 @@ type BacklightSettings struct {
}
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
if session == nil || session.RPCChannel == nil {
return
}
responseBytes, err := json.Marshal(response)
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
@ -67,6 +81,11 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
}
func writeJSONRPCEvent(event string, params any, session *Session) {
// Defensive checks: skip if session or RPC channel is not ready
if session == nil || session.RPCChannel == nil {
return // Channel not ready or already closed - this is expected during cleanup
}
request := JSONRPCEvent{
JSONRPC: "2.0",
Method: event,
@ -77,10 +96,6 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
return
}
if session == nil || session.RPCChannel == nil {
jsonRpcLogger.Info().Msg("RPC channel not available")
return
}
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
@ -91,12 +106,36 @@ func writeJSONRPCEvent(event string, params any, session *Session) {
err = session.RPCChannel.SendText(requestString)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
// Only log at debug level - closed pipe errors are expected during reconnection
scopedLogger.Debug().Err(err).Str("event", event).Msg("Could not send JSONRPC event (channel may be closing)")
return
}
}
func broadcastJSONRPCEvent(event string, params any) {
sessionManager.ForEachSession(func(s *Session) {
writeJSONRPCEvent(event, params, s)
})
}
func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
// Rate limit check (DoS protection)
if !session.CheckRPCRateLimit() {
jsonRpcLogger.Warn().
Str("sessionId", session.ID).
Msg("RPC rate limit exceeded")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]any{
"code": -32000,
"message": "Rate limit exceeded",
},
ID: 0,
}
writeJSONRPCResponse(errorResponse, session)
return
}
var request JSONRPCRequest
err := json.Unmarshal(message.Data, &request)
if err != nil {
@ -124,6 +163,231 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
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 {
handlerErr = sessionManager.ApproveSession(sessionID)
if handlerErr == nil {
go sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "approved"}
}
} 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 {
handlerErr = sessionManager.DenySession(sessionID)
if handlerErr == nil {
// Notify the denied session
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
go func() {
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session",
}, targetSession)
sessionManager.broadcastSessionListUpdate()
}()
}
result = map[string]interface{}{"status": "denied"}
}
} else {
handlerErr = errors.New("invalid sessionId parameter")
}
case "requestSessionApproval":
if session.Mode != SessionModePending {
handlerErr = errors.New("only pending sessions can request approval")
} else if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
if primary := sessionManager.GetPrimarySession(); primary != nil {
go func() {
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
"sessionId": session.ID,
"source": session.Source,
"identity": session.Identity,
"nickname": session.Nickname,
}, primary)
}()
result = map[string]interface{}{"status": "requested"}
} else {
handlerErr = errors.New("no primary session available")
}
} else {
handlerErr = errors.New("session approval not required")
}
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) {
// Check nickname uniqueness
allSessions := sessionManager.GetAllSessions()
for _, existingSession := range allSessions {
if existingSession.ID != sessionID && existingSession.Nickname == nickname {
handlerErr = fmt.Errorf("nickname '%s' is already in use by another session", nickname)
break
}
}
if handlerErr == nil {
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
}
if maxRejectionAttempts, ok := settings["maxRejectionAttempts"].(float64); ok {
currentSessionSettings.MaxRejectionAttempts = int(maxRejectionAttempts)
}
if maxSessions, ok := settings["maxSessions"].(float64); ok {
currentSessionSettings.MaxSessions = int(maxSessions)
}
if observerTimeout, ok := settings["observerTimeout"].(float64); ok {
currentSessionSettings.ObserverTimeout = int(observerTimeout)
}
// Trigger nickname auto-generation for sessions when RequireNickname changes
if sessionManager != nil {
sessionManager.updateAllSessionNicknames()
}
// Save to persistent config
if err := SaveConfig(); err != nil {
handlerErr = errors.New("failed to save session settings")
}
result = currentSessionSettings
} else {
handlerErr = errors.New("invalid settings parameter")
}
}
case "generateNickname":
// Generate a nickname based on user agent (no permissions required)
userAgent := ""
if request.Params != nil {
if ua, ok := request.Params["userAgent"].(string); ok {
userAgent = ua
}
}
// Use browser as fallback if no user agent provided
if userAgent == "" {
userAgent = "Mozilla/5.0 (Unknown) Browser"
}
result = map[string]string{
"nickname": generateNicknameFromUserAgent(userAgent),
}
default:
// Check method permissions using centralized permission system
if requiredPerm, exists := GetMethodPermission(request.Method); exists {
if !session.HasPermission(requiredPerm) {
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]any{
"code": -32603,
"message": fmt.Sprintf("Permission denied: %s required", requiredPerm),
},
ID: request.ID,
}
writeJSONRPCResponse(errorResponse, session)
return
}
}
// Fall back to regular handlers
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
@ -137,8 +401,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
writeJSONRPCResponse(errorResponse, session)
return
}
result, handlerErr = callRPCHandler(scopedLogger, handler, request.Params)
}
result, err := callRPCHandler(scopedLogger, handler, request.Params)
err = handlerErr
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
@ -154,7 +420,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully")
response := JSONRPCResponse{
JSONRPC: "2.0",
@ -1084,6 +1350,78 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil
}
func rpcGetSessions() ([]SessionData, error) {
return sessionManager.GetAllSessions(), nil
}
func rpcGetSessionData(sessionId string) (SessionData, error) {
session := sessionManager.GetSession(sessionId)
if session == nil {
return SessionData{}, ErrSessionNotFound
}
return SessionData{
ID: session.ID,
Mode: session.Mode,
Source: session.Source,
Identity: session.Identity,
CreatedAt: session.CreatedAt,
LastActive: session.LastActive,
}, nil
}
func rpcRequestPrimary(sessionId string) map[string]interface{} {
err := sessionManager.RequestPrimary(sessionId)
if err != nil {
return map[string]interface{}{
"status": "error",
"message": err.Error(),
}
}
// Check if the session was immediately promoted or queued
session := sessionManager.GetSession(sessionId)
if session == nil {
return map[string]interface{}{
"status": "error",
"message": "session not found",
}
}
return map[string]interface{}{
"status": "success",
"mode": string(session.Mode),
}
}
func rpcReleasePrimary(sessionId string) error {
return sessionManager.ReleasePrimary(sessionId)
}
func rpcTransferPrimary(fromId string, toId string) error {
return sessionManager.TransferPrimary(fromId, toId)
}
func rpcGetSessionConfig() (map[string]interface{}, error) {
maxSessions := 10
primaryTimeout := 300
if config != nil && config.MultiSession != nil {
if config.MultiSession.MaxSessions > 0 {
maxSessions = config.MultiSession.MaxSessions
}
if config.MultiSession.PrimaryTimeout > 0 {
primaryTimeout = config.MultiSession.PrimaryTimeout
}
}
return map[string]interface{}{
"enabled": true,
"maxSessions": maxSessions,
"primaryTimeout": primaryTimeout,
"allowCloudOverride": true,
}, nil
}
var (
keyboardMacroCancel context.CancelFunc
keyboardMacroLock sync.Mutex
@ -1119,8 +1457,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
IsPaste: true,
}
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
// Report to primary session if exists
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
primarySession.reportHidRPCKeyboardMacroState(s)
}
err := rpcDoExecuteKeyboardMacro(ctx, macro)
@ -1128,8 +1467,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
setKeyboardMacroCancel(nil)
s.State = false
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
primarySession.reportHidRPCKeyboardMacroState(s)
}
return err
@ -1269,4 +1608,10 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getSessions": {Func: rpcGetSessions},
"getSessionData": {Func: rpcGetSessionData, Params: []string{"sessionId"}},
"getSessionConfig": {Func: rpcGetSessionConfig},
"requestPrimary": {Func: rpcRequestPrimary, Params: []string{"sessionId"}},
"releasePrimary": {Func: rpcReleasePrimary, Params: []string{"sessionId"}},
"transferPrimary": {Func: rpcTransferPrimary, Params: []string{"fromId", "toId"}},
}

19
main.go
View File

@ -16,6 +16,22 @@ var appCtx context.Context
func Main() {
LoadConfig()
// Initialize currentSessionSettings to use config's persistent SessionSettings
if config.SessionSettings == nil {
config.SessionSettings = &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
}
_ = SaveConfig()
}
currentSessionSettings = config.SessionSettings
// Initialize global session manager (must be called after config and logger are ready)
initSessionManager()
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
@ -94,7 +110,8 @@ func Main() {
continue
}
if currentSession != nil {
// Skip update if there's an active primary session
if primarySession := sessionManager.GetPrimarySession(); primarySession != nil {
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue

View File

@ -48,12 +48,21 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
}
},
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
sessionManager.ForEachSession(func(s *Session) {
if !sessionManager.CanReceiveVideo(s, currentSessionSettings) {
return
}
if s.VideoTrack != nil {
err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
if err != nil {
nativeLogger.Warn().Err(err).Msg("error writing sample")
nativeLogger.Warn().
Str("sessionID", s.ID).
Err(err).
Msg("error writing sample to session")
}
}
})
},
})
nativeInstance.Start()

View File

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

6
ota.go
View File

@ -302,11 +302,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
logger.Info().Msg("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
broadcastJSONRPCEvent("otaState", otaState)
}()
}

View File

@ -57,12 +57,10 @@ func runATXControl() {
newBtnRSTState := line[2] == '1'
newBtnPWRState := line[3] == '1'
if currentSession != nil {
writeJSONRPCEvent("atxState", ATXState{
broadcastJSONRPCEvent("atxState", ATXState{
Power: newLedPWRState,
HDD: newLedHDDState,
}, currentSession)
}
})
if newLedHDDState != ledHDDState ||
newLedPWRState != ledPWRState ||
@ -210,9 +208,7 @@ func runDCControl() {
// Update Prometheus metrics
updateDCMetrics(dcState)
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
broadcastJSONRPCEvent("dcState", dcState)
}
}
@ -284,9 +280,16 @@ func reopenSerialPort() error {
return nil
}
func handleSerialChannel(d *webrtc.DataChannel) {
func handleSerialChannel(d *webrtc.DataChannel, session *Session) {
scopedLogger := serialLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
Uint16("data_channel_id", *d.ID()).
Str("session_id", session.ID).Logger()
// Check serial access permission
if !session.HasPermission(PermissionSerialAccess) {
handlePermissionDeniedChannel(d, "Serial port access denied: Permission required")
return
}
d.OnOpen(func() {
go func() {

1836
session_manager.go Normal file

File diff suppressed because it is too large Load Diff

77
session_permissions.go Normal file
View File

@ -0,0 +1,77 @@
package kvm
import (
"github.com/jetkvm/kvm/internal/session"
)
type (
Permission = session.Permission
PermissionSet = session.PermissionSet
SessionMode = session.SessionMode
)
const (
SessionModePrimary = session.SessionModePrimary
SessionModeObserver = session.SessionModeObserver
SessionModeQueued = session.SessionModeQueued
SessionModePending = session.SessionModePending
PermissionVideoView = session.PermissionVideoView
PermissionKeyboardInput = session.PermissionKeyboardInput
PermissionMouseInput = session.PermissionMouseInput
PermissionPaste = session.PermissionPaste
PermissionSessionTransfer = session.PermissionSessionTransfer
PermissionSessionApprove = session.PermissionSessionApprove
PermissionSessionKick = session.PermissionSessionKick
PermissionSessionRequestPrimary = session.PermissionSessionRequestPrimary
PermissionSessionReleasePrimary = session.PermissionSessionReleasePrimary
PermissionSessionManage = session.PermissionSessionManage
PermissionPowerControl = session.PermissionPowerControl
PermissionUSBControl = session.PermissionUSBControl
PermissionMountMedia = session.PermissionMountMedia
PermissionUnmountMedia = session.PermissionUnmountMedia
PermissionMountList = session.PermissionMountList
PermissionExtensionManage = session.PermissionExtensionManage
PermissionExtensionATX = session.PermissionExtensionATX
PermissionExtensionDC = session.PermissionExtensionDC
PermissionExtensionSerial = session.PermissionExtensionSerial
PermissionExtensionWOL = session.PermissionExtensionWOL
PermissionTerminalAccess = session.PermissionTerminalAccess
PermissionSerialAccess = session.PermissionSerialAccess
PermissionSettingsRead = session.PermissionSettingsRead
PermissionSettingsWrite = session.PermissionSettingsWrite
PermissionSettingsAccess = session.PermissionSettingsAccess
PermissionSystemReboot = session.PermissionSystemReboot
PermissionSystemUpdate = session.PermissionSystemUpdate
PermissionSystemNetwork = session.PermissionSystemNetwork
)
var (
GetMethodPermission = session.GetMethodPermission
)
type GetPermissionsResponse = session.GetPermissionsResponse
func (s *Session) HasPermission(perm Permission) bool {
if s == nil {
return false
}
return session.CheckPermission(s.Mode, perm)
}
func (s *Session) GetPermissions() PermissionSet {
if s == nil {
return PermissionSet{}
}
return session.GetPermissionsForMode(s.Mode)
}
func RequirePermission(s *Session, perm Permission) error {
if s == nil {
return session.RequirePermissionForMode(SessionModePending, perm)
}
if !s.HasPermission(perm) {
return session.RequirePermissionForMode(s.Mode, perm)
}
return nil
}

View File

@ -16,9 +16,16 @@ type TerminalSize struct {
Cols int `json:"cols"`
}
func handleTerminalChannel(d *webrtc.DataChannel) {
func handleTerminalChannel(d *webrtc.DataChannel, session *Session) {
scopedLogger := terminalLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
Uint16("data_channel_id", *d.ID()).
Str("session_id", session.ID).Logger()
// Check terminal access permission
if !session.HasPermission(PermissionTerminalAccess) {
handlePermissionDeniedChannel(d, "Terminal access denied: Permission required")
return
}
var ptmx *os.File
var cmd *exec.Cmd

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

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

View File

@ -0,0 +1,156 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { DEVICE_API, CLOUD_API } from "@/ui.config";
import { isOnDevice } from "@/main";
import { useUserStore, useSettingsStore } from "@/hooks/stores";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import api from "@/api";
import { Button } from "./Button";
interface AccessDeniedOverlayProps {
show: boolean;
message?: string;
onRetry?: () => void;
onRequestApproval?: () => void;
}
export default function AccessDeniedOverlay({
show,
message = "Your session access was denied",
onRetry,
onRequestApproval
}: AccessDeniedOverlayProps) {
const navigate = useNavigate();
const setUser = useUserStore(state => state.setUser);
const { clearSession, rejectionCount, incrementRejectionCount } = useSessionStore();
const { clearNickname } = useSharedSessionStore();
const { maxRejectionAttempts } = useSettingsStore();
const [countdown, setCountdown] = useState(10);
const [isRetrying, setIsRetrying] = useState(false);
const handleLogout = useCallback(async () => {
try {
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) {
console.warn("Logout API call failed, but continuing with local cleanup");
}
} catch (error) {
console.error("Logout API call failed:", error);
}
// Always clear local state and navigate, regardless of API call result
setUser(null);
clearSession();
clearNickname();
navigate("/");
}, [navigate, setUser, clearSession, clearNickname]);
useEffect(() => {
if (!show) return;
const newCount = incrementRejectionCount();
if (newCount >= maxRejectionAttempts) {
return;
}
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
handleLogout();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]);
if (!show) return null;
if (rejectionCount >= maxRejectionAttempts) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
<div className="flex items-center gap-3">
<XCircleIcon className="h-8 w-8 text-red-500 flex-shrink-0" />
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
Access Denied
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{message}
</p>
</div>
</div>
<div className="space-y-3">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<p className="text-sm text-red-800 dark:text-red-300">
The primary session has denied your access request. This could be for security reasons
or because the session is restricted.
</p>
</div>
{rejectionCount < maxRejectionAttempts && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">
<strong>Attempt {rejectionCount} of {maxRejectionAttempts}:</strong> {rejectionCount === maxRejectionAttempts - 1
? "This is your last attempt. Further rejections will hide this dialog."
: `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.`
}
</p>
</div>
)}
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Redirecting in <span className="font-mono font-bold">{countdown}</span> seconds...
</p>
<div className="flex gap-3">
{(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
<Button
onClick={async () => {
if (isRetrying) return;
setIsRetrying(true);
try {
if (onRequestApproval) {
await onRequestApproval();
} else if (onRetry) {
await onRetry();
}
} finally {
setIsRetrying(false);
}
}}
theme="primary"
size="MD"
text={isRetrying ? "Requesting..." : "Request Access Again"}
disabled={isRetrying}
fullWidth
/>
)}
<Button
onClick={() => {
handleLogout();
}}
theme="light"
size="MD"
text="Back to Login"
fullWidth
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,8 +2,8 @@ import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Fragment, useCallback, useRef, useEffect } from "react";
import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button";
import {
@ -11,14 +11,18 @@ import {
useMountMediaStore,
useSettingsStore,
useUiStore,
} from "@/hooks/stores";
useRTCStore } from "@/hooks/stores";
import Container from "@components/Container";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import SessionPopover from "@/components/popovers/SessionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function Actionbar({
requestFullscreen,
@ -33,6 +37,37 @@ export default function Actionbar({
state => state.remoteVirtualMediaState,
);
const { developerMode } = useSettingsStore();
const { currentMode, sessions, setSessions } = useSessionStore();
const { rpcDataChannel } = useRTCStore();
const { hasPermission } = usePermissions();
// Fetch sessions on mount if we have an RPC channel
useEffect(() => {
if (rpcDataChannel?.readyState === "open" && sessions.length === 0) {
const id = Math.random().toString(36).substring(2);
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id });
const handler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data);
if (response.id === id && response.result) {
setSessions(response.result);
rpcDataChannel.removeEventListener("message", handler);
}
} catch {
// 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, sessions.length, setSessions]);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
@ -44,7 +79,6 @@ export default function Actionbar({
if (!open) {
setTimeout(() => {
setDisableVideoFocusTrap(false);
console.debug("Popover is closing. Returning focus trap to video");
}, 0);
}
}
@ -60,7 +94,7 @@ export default function Actionbar({
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
{developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
<Button
size="XS"
theme="light"
@ -69,6 +103,7 @@ export default function Actionbar({
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
)}
{hasPermission(Permission.PASTE) && (
<Popover>
<PopoverButton as={Fragment}>
<Button
@ -99,6 +134,8 @@ export default function Actionbar({
}}
</PopoverPanel>
</Popover>
)}
{hasPermission(Permission.MOUNT_MEDIA) && (
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
@ -142,6 +179,8 @@ export default function Actionbar({
</PopoverPanel>
</Popover>
</div>
)}
{hasPermission(Permission.EXTENSION_WOL) && (
<div>
<Popover>
<PopoverButton as={Fragment}>
@ -194,6 +233,8 @@ export default function Actionbar({
</PopoverPanel>
</Popover>
</div>
)}
{hasPermission(Permission.KEYBOARD_INPUT) && (
<div className="hidden lg:block">
<Button
size="XS"
@ -203,9 +244,59 @@ export default function Actionbar({
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
{/* Session Control */}
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text={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>
<PopoverButton as={Fragment}>
<Button
@ -232,7 +323,9 @@ export default function Actionbar({
}}
</PopoverPanel>
</Popover>
)}
{hasPermission(Permission.KEYBOARD_INPUT) && (
<div className="block lg:hidden">
<Button
size="XS"
@ -242,6 +335,7 @@ export default function Actionbar({
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
)}
<div className="hidden md:block">
<Button
size="XS"
@ -258,6 +352,8 @@ export default function Actionbar({
}}
/>
</div>
{/* Only show Settings for sessions with settings access */}
{hasPermission(Permission.SETTINGS_ACCESS) && (
<div>
<Button
size="XS"
@ -270,6 +366,7 @@ export default function Actionbar({
}}
/>
</div>
)}
<div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />

View File

@ -12,6 +12,7 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import api from "../api";
import { isOnDevice } from "../main";
@ -37,6 +38,8 @@ export default function DashboardNavbar({
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setUser = useUserStore(state => state.setUser);
const { clearSession } = useSessionStore();
const { clearNickname } = useSharedSessionStore();
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
@ -44,9 +47,12 @@ export default function DashboardNavbar({
if (!res.ok) return;
setUser(null);
// Clear the stored session data via zustand
clearNickname();
clearSession();
// The root route will redirect to appropriate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
}, [navigate, setUser, clearNickname, clearSession]);
const { usbState } = useHidStore();

View File

@ -6,21 +6,26 @@ import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
const { executeMacro } = useKeyboard();
const { send } = useJsonRpc();
const { permissions, hasPermission } = usePermissions();
useEffect(() => {
setSendFn(send);
if (!initialized) {
// Only load macros if user has permission to read settings
if (!initialized && permissions[Permission.SETTINGS_READ] === true) {
loadMacros();
}
}, [initialized, loadMacros, setSendFn, send]);
}, [initialized, send, loadMacros, setSendFn, permissions]);
if (macros.length === 0) {
// Don't show macros if user can't provide keyboard input or if no macros exist
if (macros.length === 0 || !hasPermission(Permission.KEYBOARD_INPUT)) {
return null;
}

View File

@ -0,0 +1,263 @@
import { useState, useEffect, useRef } from "react";
import { Dialog, DialogPanel, DialogBackdrop } from "@headlessui/react";
import { UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useSettingsStore , useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { generateNickname } from "@/utils/nicknameGenerator";
import { Button } from "./Button";
type SessionRole = "primary" | "observer" | "queued" | "pending";
interface NicknameModalProps {
isOpen: boolean;
onSubmit: (nickname: string) => void | Promise<void>;
onSkip?: () => void;
title?: string;
description?: string;
isRequired?: boolean;
expectedRole?: SessionRole;
}
export default function NicknameModal({
isOpen,
onSubmit,
onSkip,
title = "Set Your Session Nickname",
description = "Add a nickname to help identify your session to other users",
isRequired,
expectedRole = "observer"
}: NicknameModalProps) {
const [nickname, setNickname] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generatedNickname, setGeneratedNickname] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { requireSessionNickname } = useSettingsStore();
const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore();
const isNicknameRequired = isRequired ?? requireSessionNickname;
// Role-based color coding
const getRoleColors = (role: SessionRole) => {
switch (role) {
case "primary":
return {
bg: "bg-green-100 dark:bg-green-900/30",
icon: "text-green-600 dark:text-green-400"
};
case "observer":
return {
bg: "bg-blue-100 dark:bg-blue-900/30",
icon: "text-blue-600 dark:text-blue-400"
};
case "queued":
return {
bg: "bg-yellow-100 dark:bg-yellow-900/30",
icon: "text-yellow-600 dark:text-yellow-400"
};
case "pending":
return {
bg: "bg-orange-100 dark:bg-orange-900/30",
icon: "text-orange-600 dark:text-orange-400"
};
default:
return {
bg: "bg-slate-100 dark:bg-slate-900/30",
icon: "text-slate-600 dark:text-slate-400"
};
}
};
const roleColors = getRoleColors(expectedRole);
// Generate nickname when modal opens and RPC is ready
useEffect(() => {
if (!isOpen || generatedNickname) return;
if (rpcDataChannel?.readyState !== "open") return;
generateNickname(send).then(nickname => {
setGeneratedNickname(nickname);
}).catch((error) => {
console.error('Backend nickname generation failed:', error);
});
}, [isOpen, generatedNickname, rpcDataChannel?.readyState, send]);
// Focus input when modal opens
useEffect(() => {
if (isOpen) {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 100);
}
}, [isOpen]);
const validateNickname = (value: string): string | null => {
if (value.length < 2) {
return "Nickname must be at least 2 characters";
}
if (value.length > 30) {
return "Nickname must be 30 characters or less";
}
if (!/^[a-zA-Z0-9\s\-_.@]+$/.test(value)) {
return "Nickname can only contain letters, numbers, spaces, and - _ . @";
}
return null;
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// Use generated nickname if input is empty
const trimmedNickname = nickname.trim() || generatedNickname;
// Validate
const validationError = validateNickname(trimmedNickname);
if (validationError) {
setError(validationError);
return;
}
setIsSubmitting(true);
setError(null);
try {
await onSubmit(trimmedNickname);
setNickname("");
setGeneratedNickname(""); // Reset generated nickname after successful submit
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to set nickname");
setIsSubmitting(false);
}
};
const handleSkip = () => {
if (!isNicknameRequired && onSkip) {
onSkip();
setNickname("");
setError(null);
setGeneratedNickname(""); // Reset generated nickname when skipping
}
};
return (
<Dialog
open={isOpen}
onClose={() => {
if (!isNicknameRequired && onSkip) {
onSkip();
setNickname("");
setError(null);
setGeneratedNickname("");
}
}}
className="relative z-50"
>
<DialogBackdrop className="fixed inset-0 bg-black/50" />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<DialogPanel className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
<div className="p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 ${roleColors.bg} rounded-lg`}>
<UserIcon className={`h-6 w-6 ${roleColors.icon}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{title}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{description}
</p>
</div>
</div>
{!isNicknameRequired && (
<button
onClick={handleSkip}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5 text-slate-500 dark:text-slate-400" />
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="nickname" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Nickname
</label>
<input
ref={inputRef}
id="nickname"
type="text"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
setError(null);
}}
placeholder={generatedNickname || "e.g., John's Laptop, Office PC, etc."}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md
bg-white dark:bg-slate-700 text-slate-900 dark:text-white
placeholder-slate-400 dark:placeholder-slate-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxLength={30}
/>
<div className="mt-1 flex justify-between items-center">
{error ? (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
) : (
<div className="space-y-1">
<p className="text-xs text-slate-500 dark:text-slate-400">
{nickname.trim() === "" && generatedNickname
? `Leave empty to use: ${generatedNickname}`
: "2-30 characters, letters, numbers, spaces, and - _ . @ allowed"}
</p>
</div>
)}
<span className="text-xs text-slate-500 dark:text-slate-400">
{nickname.length}/30
</span>
</div>
</div>
{isNicknameRequired && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">
<strong>Required:</strong> A nickname is required by the administrator to help identify sessions.
</p>
</div>
)}
<div className="flex gap-3">
<Button
type="submit"
theme="primary"
size="MD"
text="Set Nickname"
fullWidth
disabled={isSubmitting}
/>
{!isNicknameRequired && (
<Button
type="button"
onClick={handleSkip}
theme="light"
size="MD"
text="Skip"
fullWidth
disabled={isSubmitting}
/>
)}
</div>
</form>
</div>
</DialogPanel>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { ClockIcon } from "@heroicons/react/24/outline";
interface PendingApprovalOverlayProps {
show: boolean;
}
export default function PendingApprovalOverlay({ show }: PendingApprovalOverlayProps) {
const [dots, setDots] = useState("");
useEffect(() => {
if (!show) return;
const timer = setInterval(() => {
setDots(prev => (prev.length >= 3 ? "" : prev + "."));
}, 500);
return () => clearInterval(timer);
}, [show]);
if (!show) return null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
<div className="flex flex-col items-center space-y-4">
<ClockIcon className="h-12 w-12 text-amber-500 animate-pulse" />
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
Awaiting Approval{dots}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
Your session is pending approval from the primary session
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 w-full">
<p className="text-sm text-amber-800 dark:text-amber-300 text-center">
The primary user will receive a notification to approve or deny your access.
This typically takes less than 30 seconds.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<div className="h-2 w-2 bg-amber-500 rounded-full animate-pulse" />
<span>Waiting for response from primary session</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
import {
LockClosedIcon,
LockOpenIcon,
ClockIcon
} from "@heroicons/react/16/solid";
import clsx from "clsx";
import { useSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi";
import { Button } from "@/components/Button";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface SessionControlPanelProps {
sendFn: RpcSendFunction;
className?: string;
}
export default function SessionControlPanel({ sendFn, className }: SessionControlPanelProps) {
const {
currentSessionId,
currentMode,
sessions,
isRequestingPrimary,
setRequestingPrimary,
setSessionError,
canRequestPrimary
} = useSessionStore();
const { hasPermission } = usePermissions();
const handleRequestPrimary = async () => {
if (!currentSessionId || isRequestingPrimary) return;
setRequestingPrimary(true);
setSessionError(null);
try {
const result = await sessionApi.requestPrimary(sendFn, currentSessionId);
if (result.status === "success") {
if (result.mode === "primary") {
// Immediately became primary
setRequestingPrimary(false);
} else if (result.mode === "queued") {
// Request sent, waiting for approval
// Keep isRequestingPrimary true to show waiting state
}
} else if (result.status === "error") {
setSessionError(result.message || "Failed to request primary control");
setRequestingPrimary(false);
}
} catch (error) {
setSessionError(error instanceof Error ? error.message : "Unknown error");
console.error("Failed to request primary control:", error);
setRequestingPrimary(false);
}
};
const handleReleasePrimary = async () => {
if (!currentSessionId || currentMode !== "primary") return;
try {
await sessionApi.releasePrimary(sendFn, currentSessionId);
} catch (error) {
setSessionError(error instanceof Error ? error.message : "Unknown error");
console.error("Failed to release primary control:", error);
}
};
const canReleasePrimary = () => {
const otherEligibleSessions = sessions.filter(
s => s.id !== currentSessionId && (s.mode === "observer" || s.mode === "queued")
);
return otherEligibleSessions.length > 0;
};
return (
<div className={clsx("space-y-4", className)}>
{/* Current session controls */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Session Control
</h3>
{hasPermission(Permission.SESSION_RELEASE_PRIMARY) && (
<div>
<Button
size="MD"
theme="light"
text="Release Primary Control"
onClick={handleReleasePrimary}
disabled={!canReleasePrimary()}
LeadingIcon={LockOpenIcon}
fullWidth
/>
{!canReleasePrimary() && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Cannot release control - no other sessions available to take primary
</p>
)}
</div>
)}
{hasPermission(Permission.SESSION_REQUEST_PRIMARY) && (
<>
{isRequestingPrimary ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<ClockIcon className="h-5 w-5 text-blue-600 dark:text-blue-400 animate-pulse" />
<span className="text-sm text-blue-700 dark:text-blue-300">
Waiting for approval from primary session...
</span>
</div>
) : (
<Button
size="MD"
theme="primary"
text="Request Primary Control"
onClick={handleRequestPrimary}
disabled={!canRequestPrimary()}
LeadingIcon={LockClosedIcon}
fullWidth
/>
)}
</>
)}
{currentMode === "queued" && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20">
<ClockIcon className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-700 dark:text-yellow-300">
Waiting for primary control...
</span>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { formatters } from "@/utils";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
interface Session {
id: string;
mode: string;
nickname?: string;
identity?: string;
source?: string;
createdAt?: string;
}
interface SessionsListProps {
sessions: Session[];
currentSessionId?: string;
onEditNickname?: (sessionId: string) => void;
onApprove?: (sessionId: string) => void;
onDeny?: (sessionId: string) => void;
onTransfer?: (sessionId: string) => void;
formatDuration?: (createdAt: string) => string;
}
export default function SessionsList({
sessions,
currentSessionId,
onEditNickname,
onApprove,
onDeny,
onTransfer,
formatDuration = (createdAt: string) => formatters.timeAgo(new Date(createdAt)) || ""
}: SessionsListProps) {
const { hasPermission } = usePermissions();
return (
<div className="space-y-2">
{sessions.map(session => (
<div
key={session.id}
className={clsx(
"p-2 rounded-md border text-xs",
session.id === currentSessionId
? "border-blue-500 bg-blue-50 dark:bg-blue-900/10"
: session.mode === "pending"
? "border-orange-300 dark:border-orange-800/50 bg-orange-50/50 dark:bg-orange-900/10"
: "border-slate-200 dark:border-slate-700"
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SessionModeBadge mode={session.mode} />
{session.id === currentSessionId && (
<span className="text-blue-600 dark:text-blue-400 font-medium">(You)</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 dark:text-slate-400">
{session.createdAt ? formatDuration(session.createdAt) : ""}
</span>
{/* Show approve/deny for pending sessions if user has permission */}
{session.mode === "pending" && hasPermission(Permission.SESSION_APPROVE) && onApprove && onDeny && (
<div className="flex items-center gap-1">
<button
onClick={() => onApprove(session.id)}
className="p-1 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
title="Approve session"
>
<CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
</button>
<button
onClick={() => onDeny(session.id)}
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
title="Deny session"
>
<XMarkIcon className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
</button>
</div>
)}
{/* Show Transfer button if user has permission to transfer */}
{hasPermission(Permission.SESSION_TRANSFER) && session.mode === "observer" && session.id !== currentSessionId && onTransfer && (
<button
onClick={() => onTransfer(session.id)}
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-400 transition-colors"
title="Transfer primary control"
>
Transfer
</button>
)}
{/* Allow users with session manage permission to edit any nickname, or anyone to edit their own */}
{onEditNickname && (hasPermission(Permission.SESSION_MANAGE) || session.id === currentSessionId) && (
<button
onClick={() => onEditNickname(session.id)}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
title="Edit nickname"
>
<PencilIcon className="h-3 w-3 text-slate-500 dark:text-slate-400" />
</button>
)}
</div>
</div>
<div className="mt-1 space-y-1">
{session.nickname && (
<p className="text-slate-700 dark:text-slate-200 font-medium">
{session.nickname}
</p>
)}
{session.identity && (
<p className="text-slate-600 dark:text-slate-300 text-xs">
{session.source === "cloud" ? "☁️ " : ""}{session.identity}
</p>
)}
{session.mode === "pending" && (
<p className="text-orange-600 dark:text-orange-400 text-xs italic">
Awaiting approval
</p>
)}
</div>
</div>
))}
</div>
);
}
export function SessionModeBadge({ mode }: { mode: string }) {
const getBadgeStyle = () => {
switch (mode) {
case "primary":
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
case "observer":
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
case "queued":
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
case "pending":
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
default:
return "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400";
}
};
return (
<span className={clsx(
"inline-flex items-center px-1.5 py-0.5 text-xs font-medium rounded-full",
getBadgeStyle()
)}>
{mode}
</span>
);
}

View File

@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { XMarkIcon, UserIcon, GlobeAltIcon, ComputerDesktopIcon } from "@heroicons/react/20/solid";
import { Button } from "./Button";
type RequestType = "session_approval" | "primary_control";
interface UnifiedSessionRequest {
id: string; // sessionId or requestId
type: RequestType;
source: "local" | "cloud" | string; // Allow string for IP addresses
identity?: string;
nickname?: string;
}
interface UnifiedSessionRequestDialogProps {
request: UnifiedSessionRequest | null;
onApprove: (id: string) => void | Promise<void>;
onDeny: (id: string) => void | Promise<void>;
onDismiss?: () => void;
onClose: () => void;
}
export default function UnifiedSessionRequestDialog({
request,
onApprove,
onDeny,
onDismiss,
onClose
}: UnifiedSessionRequestDialogProps) {
const [timeRemaining, setTimeRemaining] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [hasTimedOut, setHasTimedOut] = useState(false);
useEffect(() => {
if (!request) return;
const isSessionApproval = request.type === "session_approval";
const initialTime = isSessionApproval ? 60 : 0; // 60s for session approval, no timeout for primary control
setTimeRemaining(initialTime);
setIsProcessing(false);
setHasTimedOut(false);
// Only start timer for session approval requests
if (isSessionApproval) {
const timer = setInterval(() => {
setTimeRemaining(prev => {
const newTime = prev - 1;
if (newTime <= 0) {
clearInterval(timer);
setHasTimedOut(true);
return 0;
}
return newTime;
});
}, 1000);
return () => clearInterval(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request?.id, request?.type]); // Only depend on stable properties to avoid unnecessary re-renders
// Handle auto-deny when timeout occurs
useEffect(() => {
if (hasTimedOut && !isProcessing && request) {
setIsProcessing(true);
Promise.resolve(onDeny(request.id))
.catch(error => {
console.error("Failed to auto-deny request:", error);
})
.finally(() => {
onClose();
});
}
}, [hasTimedOut, isProcessing, request, onDeny, onClose]);
if (!request) return null;
const isSessionApproval = request.type === "session_approval";
const isPrimaryControl = request.type === "primary_control";
// Determine if source is cloud, local, or IP address
const getSourceInfo = () => {
if (request.source === "cloud") {
return {
type: "cloud",
label: "Cloud Session",
icon: GlobeAltIcon,
iconColor: "text-blue-500"
};
} else if (request.source === "local") {
return {
type: "local",
label: "Local Session",
icon: ComputerDesktopIcon,
iconColor: "text-green-500"
};
} else {
// Assume it's an IP address or hostname
return {
type: "ip",
label: request.source,
icon: ComputerDesktopIcon,
iconColor: "text-green-500"
};
}
};
const sourceInfo = getSourceInfo();
const getTitle = () => {
if (isSessionApproval) return "New Session Request";
if (isPrimaryControl) return "Primary Control Request";
return "Session Request";
};
const getDescription = () => {
if (isSessionApproval) return "A new session is attempting to connect to this device:";
if (isPrimaryControl) return "A user is requesting primary control of this session:";
return "A user is making a request:";
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="flex items-center justify-between p-4 border-b dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{getTitle()}
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
<p className="text-slate-700 dark:text-slate-300">
{getDescription()}
</p>
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-2">
{/* Session type - always show with icon for both session approval and primary control */}
<div className="flex items-center gap-2">
<sourceInfo.icon className={`h-5 w-5 ${sourceInfo.iconColor}`} />
<span className="text-sm font-medium text-slate-900 dark:text-white">
{sourceInfo.type === "cloud" ? "Cloud Session" :
sourceInfo.type === "local" ? "Local Session" :
`Local Session`}
</span>
{sourceInfo.type === "ip" && (
<span className="text-sm text-slate-600 dark:text-slate-400">
({sourceInfo.label})
</span>
)}
</div>
{/* Nickname - always show with icon for consistency */}
{request.nickname && (
<div className="flex items-center gap-2">
<UserIcon className="h-5 w-5 text-slate-400" />
<span className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-medium text-slate-600 dark:text-slate-400">Nickname:</span>{" "}
<span className="font-medium text-slate-900 dark:text-white">{request.nickname}</span>
</span>
</div>
)}
{/* Identity/User */}
{request.identity && (
<div className={`text-sm ${isSessionApproval ? 'text-slate-600 dark:text-slate-400' : ''}`}>
{isSessionApproval ? (
<p>Identity: {request.identity}</p>
) : (
<p>
<span className="font-medium text-slate-600 dark:text-slate-400">User:</span>{" "}
<span className="text-slate-900 dark:text-white">{request.identity}</span>
</p>
)}
</div>
)}
</div>
{/* Security Note - only for session approval */}
{isSessionApproval && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">
<strong>Security Note:</strong> Only approve sessions you recognize.
Approved sessions will have observer access and can request primary control.
</p>
</div>
)}
{/* Auto-deny timer - only for session approval */}
{isSessionApproval && (
<div className="text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">
Auto-deny in <span className="font-mono font-bold">{timeRemaining}</span> seconds
</p>
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-3">
<Button
onClick={async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
await onApprove(request.id);
onClose();
} catch (error) {
console.error("Failed to approve request:", error);
setIsProcessing(false);
}
}}
theme="primary"
size="MD"
text="Approve"
fullWidth
disabled={isProcessing}
/>
<Button
onClick={async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
await onDeny(request.id);
onClose();
} catch (error) {
console.error("Failed to deny request:", error);
setIsProcessing(false);
}
}}
theme="light"
size="MD"
text="Deny"
fullWidth
disabled={isProcessing}
/>
</div>
{onDismiss && (
<Button
onClick={() => {
onDismiss();
onClose();
}}
theme="light"
size="MD"
text="Dismiss (Hide Request)"
fullWidth
disabled={isProcessing}
/>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -14,6 +14,8 @@ import {
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import useMouse from "@/hooks/useMouse";
import {
@ -35,6 +37,7 @@ export default function WebRTCVideo() {
// Store hooks
const settings = useSettingsStore();
const { hasPermission } = usePermissions();
const { handleKeyPress, resetKeyboardState } = useKeyboard();
const {
getRelMouseMoveHandler,
@ -214,29 +217,47 @@ export default function WebRTCVideo() {
document.addEventListener("fullscreenchange", handleFullscreenChange);
}, [releaseKeyboardLock]);
const absMouseMoveHandler = useMemo(
() => getAbsMouseMoveHandler({
const absMouseMoveHandler = useMemo(() => {
const handler = getAbsMouseMoveHandler({
videoClientWidth,
videoClientHeight,
videoWidth,
videoHeight,
}),
[getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight],
);
});
return (e: MouseEvent) => {
// Only allow input if user has mouse permission
if (!hasPermission(Permission.MOUSE_INPUT)) return;
handler(e);
};
}, [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight, hasPermission]);
const relMouseMoveHandler = useMemo(
() => getRelMouseMoveHandler(),
[getRelMouseMoveHandler],
);
const relMouseMoveHandler = useMemo(() => {
const handler = getRelMouseMoveHandler();
return (e: MouseEvent) => {
// Only allow input if user has mouse permission
if (!hasPermission(Permission.MOUSE_INPUT)) return;
handler(e);
};
}, [getRelMouseMoveHandler, hasPermission]);
const mouseWheelHandler = useMemo(
() => getMouseWheelHandler(),
[getMouseWheelHandler],
);
const mouseWheelHandler = useMemo(() => {
const handler = getMouseWheelHandler();
return (e: WheelEvent) => {
// Only allow input if user has mouse permission
if (!hasPermission(Permission.MOUSE_INPUT)) return;
handler(e);
};
}, [getMouseWheelHandler, hasPermission]);
const keyDownHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
// Only allow input if user has keyboard permission
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
return;
}
if (e.repeat) return;
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
@ -252,11 +273,9 @@ export default function WebRTCVideo() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey && hidKey < 0xE0) {
setTimeout(() => {
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
handleKeyPress(hidKey, false);
}, 10);
}
console.debug(`Key down: ${hidKey}`);
handleKeyPress(hidKey, true);
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
@ -264,17 +283,22 @@ export default function WebRTCVideo() {
// we'll never see the keyup event because the browser is going to lose
// focus so set a deferred keyup after a short delay
setTimeout(() => {
console.debug(`Forcing the left meta key release`);
handleKeyPress(hidKey, false);
}, 100);
}
},
[handleKeyPress, isKeyboardLockActive],
[handleKeyPress, isKeyboardLockActive, hasPermission],
);
const keyUpHandler = useCallback(
async (e: KeyboardEvent) => {
e.preventDefault();
// Only allow input if user has keyboard permission
if (!hasPermission(Permission.KEYBOARD_INPUT)) {
return;
}
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
@ -283,10 +307,9 @@ export default function WebRTCVideo() {
return;
}
console.debug(`Key up: ${hidKey}`);
handleKeyPress(hidKey, false);
},
[handleKeyPress],
[handleKeyPress, hasPermission],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -297,7 +320,6 @@ export default function WebRTCVideo() {
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current.paused) {
console.debug("Force playing video");
videoElm.current.play();
}
}
@ -556,7 +578,7 @@ export default function WebRTCVideo() {
)}
</div>
</div>
<VirtualKeyboard />
{hasPermission(Permission.KEYBOARD_INPUT) && <VirtualKeyboard />}
</div>
</div>
</div>

View File

@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from "react";
import {
UserGroupIcon,
ArrowPathIcon,
PencilIcon,
} from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import SessionControlPanel from "@/components/SessionControlPanel";
import NicknameModal from "@/components/NicknameModal";
import SessionsList, { SessionModeBadge } from "@/components/SessionsList";
import { sessionApi } from "@/api/sessionApi";
export default function SessionPopover() {
const {
currentSessionId,
currentMode,
sessions,
sessionError,
setSessions,
} = useSessionStore();
const { setNickname } = useSharedSessionStore();
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNicknameModal, setShowNicknameModal] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const { send } = useJsonRpc();
// Adapter function to match existing callback pattern
const sendRpc = useCallback((method: string, params: Record<string, unknown>, callback?: (response: { result?: unknown; error?: { message: string } }) => void) => {
send(method, params, (response) => {
if (callback) callback(response);
});
}, [send]);
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
const refreshedSessions = await sessionApi.getSessions(sendRpc);
setSessions(refreshedSessions);
} catch (error) {
console.error("Failed to refresh sessions:", error);
} finally {
setIsRefreshing(false);
}
};
// Fetch sessions on mount
useEffect(() => {
if (sessions.length === 0) {
sessionApi.getSessions(sendRpc)
.then(sessions => setSessions(sessions))
.catch(error => console.error("Failed to fetch sessions:", error));
}
}, [sendRpc, sessions.length, setSessions]);
return (
<div className="w-full rounded-lg bg-white dark:bg-slate-800 shadow-lg border border-slate-200 dark:border-slate-700">
{/* Header */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Session Management
</h3>
</div>
<button
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
onClick={handleRefresh}
disabled={isRefreshing}
>
<ArrowPathIcon className={clsx("h-4 w-4 text-slate-600 dark:text-slate-400", isRefreshing && "animate-spin")} />
</button>
</div>
</div>
{/* Session Error */}
{sessionError && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 border-b border-red-200 dark:border-red-800">
<p className="text-xs text-red-700 dark:text-red-400">{sessionError}</p>
</div>
)}
{/* Current Session */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Your Session</span>
<button
onClick={() => {
setEditingSessionId(currentSessionId);
setShowNicknameModal(true);
}}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
title="Edit nickname"
>
<PencilIcon className="h-3 w-3 text-slate-500 dark:text-slate-400" />
</button>
</div>
<SessionModeBadge mode={currentMode || "unknown"} />
</div>
{currentSessionId && (
<>
{/* Display current session nickname if exists */}
{sessions.find(s => s.id === currentSessionId)?.nickname && (
<div className="flex items-center justify-between text-xs">
<span className="text-slate-600 dark:text-slate-400">Nickname:</span>
<span className="font-medium text-slate-900 dark:text-white">
{sessions.find(s => s.id === currentSessionId)?.nickname}
</span>
</div>
)}
<div className="mt-3">
<SessionControlPanel sendFn={sendRpc} />
</div>
</>
)}
</div>
</div>
{/* Active Sessions List */}
<div className="p-4 max-h-64 overflow-y-auto">
<div className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
Active Sessions ({sessions.length})
</div>
{sessions.length > 0 ? (
<SessionsList
sessions={sessions}
currentSessionId={currentSessionId || undefined}
onEditNickname={(sessionId) => {
setEditingSessionId(sessionId);
setShowNicknameModal(true);
}}
onApprove={(sessionId) => {
sendRpc("approveNewSession", { sessionId }, (response) => {
if (response.error) {
console.error("Failed to approve session:", response.error);
} else {
handleRefresh();
}
});
}}
onDeny={(sessionId) => {
sendRpc("denyNewSession", { sessionId }, (response) => {
if (response.error) {
console.error("Failed to deny session:", response.error);
} else {
handleRefresh();
}
});
}}
onTransfer={async (sessionId) => {
try {
await sessionApi.transferPrimary(sendRpc, currentSessionId!, sessionId);
handleRefresh();
} catch (error) {
console.error("Failed to transfer primary:", error);
}
}}
/>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">No active sessions</p>
)}
</div>
<NicknameModal
isOpen={showNicknameModal}
title={editingSessionId === currentSessionId
? (sessions.find(s => s.id === currentSessionId)?.nickname ? "Update Your Nickname" : "Set Your Nickname")
: `Set Nickname for ${sessions.find(s => s.id === editingSessionId)?.mode || 'Session'}`}
description={editingSessionId === currentSessionId
? "Choose a nickname to help identify your session to others"
: "Choose a nickname to help identify this session"}
onSubmit={async (nickname) => {
if (editingSessionId && sendRpc) {
try {
await sessionApi.updateNickname(sendRpc, editingSessionId, nickname);
if (editingSessionId === currentSessionId) {
setNickname(nickname);
}
setShowNicknameModal(false);
setEditingSessionId(null);
handleRefresh();
} catch (error) {
console.error("Failed to update nickname:", error);
throw error;
}
}
}}
onSkip={() => {
setShowNicknameModal(false);
setEditingSessionId(null);
}}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { createContext } from "react";
import { PermissionsContextValue } from "@/hooks/usePermissions";
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);

View File

@ -329,6 +329,15 @@ export interface SettingsState {
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
requireSessionNickname: boolean;
setRequireSessionNickname: (required: boolean) => void;
requireSessionApproval: boolean;
setRequireSessionApproval: (required: boolean) => void;
maxRejectionAttempts: number;
setMaxRejectionAttempts: (attempts: number) => void;
displayRotation: string;
setDisplayRotation: (rotation: string) => void;
@ -369,6 +378,15 @@ export const useSettingsStore = create(
developerMode: false,
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
requireSessionNickname: false,
setRequireSessionNickname: (required: boolean) => set({ requireSessionNickname: required }),
requireSessionApproval: true,
setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }),
maxRejectionAttempts: 3,
setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }),
displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRTCStore } from "@/hooks/stores";
@ -36,6 +36,12 @@ let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const { rpcDataChannel } = useRTCStore();
const onRequestRef = useRef(onRequest);
// Update ref when callback changes
useEffect(() => {
onRequestRef.current = onRequest;
}, [onRequest]);
const send = useCallback(
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
@ -59,7 +65,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
// The "API" can also "request" data from the client
// If the payload has a method, it's a request
if ("method" in payload) {
if (onRequest) onRequest(payload);
if (onRequestRef.current) onRequestRef.current(payload);
return;
}
@ -79,7 +85,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.removeEventListener("message", messageHandler);
};
},
[rpcDataChannel, onRequest]);
[rpcDataChannel]); // Remove onRequest from dependencies
return { send };
}

View File

@ -0,0 +1,34 @@
import { useContext } from "react";
import { PermissionsContext } from "@/contexts/PermissionsContext";
import { Permission } from "@/types/permissions";
export interface PermissionsContextValue {
permissions: Record<string, boolean>;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (...perms: Permission[]) => boolean;
hasAllPermissions: (...perms: Permission[]) => boolean;
isPrimary: () => boolean;
isObserver: () => boolean;
isPending: () => boolean;
}
export function usePermissions(): PermissionsContextValue {
const context = useContext(PermissionsContext);
if (context === undefined) {
return {
permissions: {},
isLoading: true,
hasPermission: () => false,
hasAnyPermission: () => false,
hasAllPermissions: () => false,
isPrimary: () => false,
isObserver: () => false,
isPending: () => false,
};
}
return context;
}

View File

@ -0,0 +1,167 @@
import { useEffect, useRef } from "react";
import { useSessionStore, SessionInfo } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { sessionApi } from "@/api/sessionApi";
import { notify } from "@/notifications";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface SessionEventData {
sessions: SessionInfo[];
yourMode: string;
}
interface ModeChangedData {
mode: string;
}
interface ConnectionModeChangedData {
newMode: string;
}
export function useSessionEvents(sendFn: RpcSendFunction | null) {
const {
currentMode,
setSessions,
updateSessionMode,
setSessionError
} = useSessionStore();
const sendFnRef = useRef(sendFn);
sendFnRef.current = sendFn;
const handleSessionEvent = (method: string, params: unknown) => {
switch (method) {
case "sessionsUpdated":
handleSessionsUpdated(params as SessionEventData);
break;
case "modeChanged":
handleModeChanged(params as ModeChangedData);
break;
case "connectionModeChanged":
handleConnectionModeChanged(params as ConnectionModeChangedData);
break;
case "hidReadyForPrimary":
handleHidReadyForPrimary();
break;
case "otherSessionConnected":
handleOtherSessionConnected();
break;
default:
break;
}
};
const handleSessionsUpdated = (data: SessionEventData) => {
if (data.sessions) {
setSessions(data.sessions);
}
// CRITICAL: Only update mode, never show notifications from sessionsUpdated
// Notifications are exclusively handled by handleModeChanged to prevent duplicates
if (data.yourMode && data.yourMode !== currentMode) {
updateSessionMode(data.yourMode as "primary" | "observer" | "queued" | "pending");
}
};
// Debounce notifications to prevent rapid-fire duplicates
const lastNotificationRef = useRef<{mode: string, timestamp: number}>({mode: "", timestamp: 0});
const handleModeChanged = (data: ModeChangedData) => {
if (data.mode) {
// Get the most current mode from the store to avoid race conditions
const { currentMode: currentModeFromStore } = useSessionStore.getState();
const previousMode = currentModeFromStore;
updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending");
if (previousMode === "queued" && data.mode !== "queued") {
const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false);
}
if (previousMode === "pending" && data.mode === "observer") {
const { resetRejectionCount } = useSessionStore.getState();
resetRejectionCount();
}
// HID re-initialization is now handled automatically by permission changes in usePermissions
// CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events
const now = Date.now();
const lastNotification = lastNotificationRef.current;
// Only show notification if:
// 1. Mode actually changed, AND
// 2. Haven't shown the same notification in the last 2 seconds
const shouldNotify = previousMode !== data.mode &&
(lastNotification.mode !== data.mode || now - lastNotification.timestamp > 2000);
if (shouldNotify) {
if (data.mode === "primary") {
notify.success("Primary control granted");
lastNotificationRef.current = {mode: "primary", timestamp: now};
} else if (data.mode === "observer" && previousMode === "primary") {
notify.info("Primary control released");
lastNotificationRef.current = {mode: "observer", timestamp: now};
}
}
}
};
const handleConnectionModeChanged = (data: ConnectionModeChangedData) => {
if (data.newMode) {
handleModeChanged({ mode: data.newMode });
}
};
const handleHidReadyForPrimary = () => {
const { rpcHidChannel } = useRTCStore.getState();
if (rpcHidChannel?.readyState === "open") {
rpcHidChannel.dispatchEvent(new Event("open"));
}
};
const handleOtherSessionConnected = () => {
notify.warning("Another session is connecting", {
duration: 5000
});
};
useEffect(() => {
if (!sendFnRef.current) return;
const fetchSessions = async () => {
try {
const sessions = await sessionApi.getSessions(sendFnRef.current!);
setSessions(sessions);
} catch (error) {
console.error("Failed to fetch sessions:", error);
setSessionError("Failed to fetch session information");
}
};
fetchSessions();
}, [setSessions, setSessionError]);
useEffect(() => {
if (!sendFnRef.current) return;
const intervalId = setInterval(async () => {
if (!sendFnRef.current) return;
try {
const sessions = await sessionApi.getSessions(sendFnRef.current);
setSessions(sessions);
} catch {
// Silently fail on refresh errors
}
}, 30000); // Refresh every 30 seconds
return () => clearInterval(intervalId);
}, [setSessions]);
return {
handleSessionEvent
};
}

View File

@ -0,0 +1,173 @@
import { useEffect, useCallback, useState } from "react";
import { useSessionStore } from "@/stores/sessionStore";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useSettingsStore } from "@/hooks/stores";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface SessionResponse {
sessionId?: string;
mode?: string;
}
interface PrimaryControlRequest {
requestId: string;
identity: string;
source: string;
nickname?: string;
}
interface NewSessionRequest {
sessionId: string;
source: "local" | "cloud";
identity?: string;
nickname?: string;
}
export function useSessionManagement(sendFn: RpcSendFunction | null) {
const {
setCurrentSession,
clearSession
} = useSessionStore();
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
const { requireSessionApproval } = useSettingsStore();
const { handleSessionEvent } = useSessionEvents(sendFn);
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
const handleSessionResponse = useCallback((response: SessionResponse) => {
if (response.sessionId && response.mode) {
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
}
}, [setCurrentSession]);
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => {
if (response.error) {
console.error("Failed to approve primary request:", response.error);
reject(new Error(response.error.message || "Failed to approve"));
} else {
setPrimaryControlRequest(null);
resolve();
}
});
});
}, [sendFn]);
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => {
if (response.error) {
console.error("Failed to deny primary request:", response.error);
reject(new Error(response.error.message || "Failed to deny"));
} else {
setPrimaryControlRequest(null);
resolve();
}
});
});
}, [sendFn]);
const handleApproveNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("approveNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => {
if (response.error) {
console.error("Failed to approve new session:", response.error);
reject(new Error(response.error.message || "Failed to approve"));
} else {
setNewSessionRequest(null);
resolve();
}
});
});
}, [sendFn]);
const handleDenyNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
return new Promise<void>((resolve, reject) => {
sendFn("denyNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => {
if (response.error) {
console.error("Failed to deny new session:", response.error);
reject(new Error(response.error.message || "Failed to deny"));
} else {
setNewSessionRequest(null);
resolve();
}
});
});
}, [sendFn]);
const handleRpcEvent = useCallback((method: string, params: unknown) => {
if (method === "sessionsUpdated" ||
method === "modeChanged" ||
method === "connectionModeChanged" ||
method === "otherSessionConnected") {
handleSessionEvent(method, params);
}
if (method === "newSessionPending" && requireSessionApproval) {
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params as NewSessionRequest);
}
}
if (method === "primaryControlRequested") {
setPrimaryControlRequest(params as PrimaryControlRequest);
}
if (method === "primaryControlApproved") {
const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false);
}
if (method === "primaryControlDenied") {
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
setRequestingPrimary(false);
setSessionError("Your primary control request was denied");
}
if (method === "sessionAccessDenied") {
const { setSessionError } = useSessionStore.getState();
const errorParams = params as { message?: string };
setSessionError(errorParams.message || "Session access was denied by the primary session");
}
}, [handleSessionEvent, hasPermission, isLoadingPermissions, requireSessionApproval]);
useEffect(() => {
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(null);
}
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
useEffect(() => {
return () => {
clearSession();
};
}, [clearSession]);
return {
handleSessionResponse,
handleRpcEvent,
primaryControlRequest,
handleApprovePrimaryRequest,
handleDenyPrimaryRequest,
closePrimaryControlRequest: () => setPrimaryControlRequest(null),
newSessionRequest,
handleApproveNewSession,
handleDenyNewSession,
closeNewSessionRequest: () => setNewSessionRequest(null)
};
}

View File

@ -49,6 +49,7 @@ const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.sett
const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros"));
const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add"));
const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit"));
const SettingsMultiSessionsRoute = lazy(() => import("@routes/devices.$id.settings.multi-session"));
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
@ -211,6 +212,10 @@ if (isOnDevice) {
},
],
},
{
path: "sessions",
element: <SettingsMultiSessionsRoute />,
},
],
},
],
@ -344,6 +349,10 @@ if (isOnDevice) {
},
],
},
{
path: "sessions",
element: <SettingsMultiSessionsRoute />,
},
],
},
],

View File

@ -1,6 +1,11 @@
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import React, { useEffect } from "react";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import {
CheckCircleIcon,
XCircleIcon,
InformationCircleIcon,
ExclamationTriangleIcon
} from "@heroicons/react/20/solid";
import Card from "@/components/Card";
@ -57,6 +62,32 @@ const notifications = {
{ duration: 2000, ...options },
);
},
info: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
<ToastContent
icon={<InformationCircleIcon className="w-5 h-5 text-blue-500 dark:text-blue-400" />}
message={message}
t={t}
/>
),
{ duration: 2000, ...options },
);
},
warning: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
<ToastContent
icon={<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500 dark:text-yellow-400" />}
message={message}
t={t}
/>
),
{ duration: 3000, ...options },
);
},
};
function useMaxToasts(max: number) {
@ -82,7 +113,12 @@ export function Notifications({
}
// eslint-disable-next-line react-refresh/only-export-components
export default Object.assign(Notifications, {
export const notify = {
success: notifications.success,
error: notifications.error,
});
info: notifications.info,
warning: notifications.warning,
};
// eslint-disable-next-line react-refresh/only-export-components
export default Object.assign(Notifications, notify);

View File

@ -0,0 +1,106 @@
import { useState, useEffect, useRef, useCallback, ReactNode } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { Permission } from "@/types/permissions";
import { PermissionsContextValue } from "@/hooks/usePermissions";
import { PermissionsContext } from "@/contexts/PermissionsContext";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface PermissionsResponse {
mode: string;
permissions: Record<string, boolean>;
}
export function PermissionsProvider({ children }: { children: ReactNode }) {
const { currentMode } = useSessionStore();
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
setIsLoading(true);
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
const { send } = useJsonRpc();
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
pollPermissions(send);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMode, rpcDataChannel?.readyState]);
const hasPermission = useCallback((permission: Permission): boolean => {
return permissions[permission] === true;
}, [permissions]);
const hasAnyPermission = useCallback((...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
}, [hasPermission]);
const hasAllPermissions = useCallback((...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
}, [hasPermission]);
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
setRpcHidProtocolVersion(null);
import("@/hooks/hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
setTimeout(() => {
if (rpcHidChannel?.readyState === "open") {
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
try {
const data = handshakeMessage.marshal();
rpcHidChannel.send(data as unknown as ArrayBuffer);
console.info("Sent HID handshake after permission change");
} catch (e) {
console.error("Failed to send HID handshake", e);
}
}
}, 100);
});
}
previousCanControl.current = currentCanControl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
const isPrimary = useCallback(() => currentMode === "primary", [currentMode]);
const isObserver = useCallback(() => currentMode === "observer", [currentMode]);
const isPending = useCallback(() => currentMode === "pending", [currentMode]);
const value: PermissionsContextValue = {
permissions,
isLoading,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isPrimary,
isObserver,
isPending,
};
return (
<PermissionsContext.Provider value={value}>
{children}
</PermissionsContext.Provider>
);
}

View File

@ -201,6 +201,7 @@ export default function SettingsAccessIndexRoute() {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
}, [send, getCloudState, getTLSState]);
return (
@ -327,6 +328,7 @@ export default function SettingsAccessIndexRoute() {
</>
)}
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"

View File

@ -6,6 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
@ -15,6 +17,7 @@ export default function SettingsHardwareRoute() {
const { send } = useJsonRpc();
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const { hasPermission, isLoading, permissions } = usePermissions();
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
@ -58,7 +61,10 @@ export default function SettingsHardwareRoute() {
});
};
// Check permissions before fetching settings data
useEffect(() => {
// Only fetch settings if user has permission
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
@ -68,7 +74,26 @@ export default function SettingsHardwareRoute() {
const result = resp.result as BacklightSettings;
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 (
<div className="space-y-4">

View File

@ -0,0 +1,374 @@
import { useEffect, useState } from "react";
import {
UserGroupIcon,
} from "@heroicons/react/16/solid";
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { useSettingsStore } from "@/hooks/stores";
import { notify } from "@/notifications";
import Card from "@/components/Card";
import Checkbox from "@/components/Checkbox";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { SettingsItem } from "@/components/SettingsItem";
export default function SessionsSettings() {
const { send } = useJsonRpc();
const { hasPermission } = usePermissions();
const canModifySettings = hasPermission(Permission.SETTINGS_WRITE);
const {
requireSessionNickname,
setRequireSessionNickname,
requireSessionApproval,
setRequireSessionApproval,
maxRejectionAttempts,
setMaxRejectionAttempts
} = useSettingsStore();
const [reconnectGrace, setReconnectGrace] = useState(10);
const [primaryTimeout, setPrimaryTimeout] = useState(300);
const [privateKeystrokes, setPrivateKeystrokes] = useState(false);
const [maxSessions, setMaxSessions] = useState(10);
const [observerTimeout, setObserverTimeout] = useState(120);
useEffect(() => {
send("getSessionSettings", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
console.error("Failed to get session settings:", response.error);
} else {
const settings = response.result as {
requireApproval: boolean;
requireNickname: boolean;
reconnectGrace?: number;
primaryTimeout?: number;
privateKeystrokes?: boolean;
maxRejectionAttempts?: number;
maxSessions?: number;
observerTimeout?: number;
};
setRequireSessionApproval(settings.requireApproval);
setRequireSessionNickname(settings.requireNickname);
if (settings.reconnectGrace !== undefined) {
setReconnectGrace(settings.reconnectGrace);
}
if (settings.primaryTimeout !== undefined) {
setPrimaryTimeout(settings.primaryTimeout);
}
if (settings.privateKeystrokes !== undefined) {
setPrivateKeystrokes(settings.privateKeystrokes);
}
if (settings.maxRejectionAttempts !== undefined) {
setMaxRejectionAttempts(settings.maxRejectionAttempts);
}
if (settings.maxSessions !== undefined) {
setMaxSessions(settings.maxSessions);
}
if (settings.observerTimeout !== undefined) {
setObserverTimeout(settings.observerTimeout);
}
}
});
}, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]);
const updateSessionSettings = (updates: Partial<{
requireApproval: boolean;
requireNickname: boolean;
reconnectGrace: number;
primaryTimeout: number;
privateKeystrokes: boolean;
maxRejectionAttempts: number;
maxSessions: number;
observerTimeout: number;
}>) => {
if (!canModifySettings) {
notify.error("Only the primary session can change this setting");
return;
}
send("setSessionSettings", {
settings: {
requireApproval: requireSessionApproval,
requireNickname: requireSessionNickname,
reconnectGrace: reconnectGrace,
primaryTimeout: primaryTimeout,
privateKeystrokes: privateKeystrokes,
maxRejectionAttempts: maxRejectionAttempts,
maxSessions: maxSessions,
observerTimeout: observerTimeout,
...updates
}
}, (response: JsonRpcResponse) => {
if ("error" in response) {
console.error("Failed to update session settings:", response.error);
notify.error("Failed to update session settings");
}
});
};
return (
<div className="space-y-6">
<SettingsPageHeader
title="Multi-Session Access"
description="Configure multi-session access and control settings"
/>
{!canModifySettings && (
<Card className="border-amber-500/20 bg-amber-50 dark:bg-amber-900/10">
<div className="p-4 text-sm text-amber-700 dark:text-amber-400">
<strong>Note:</strong> Only the primary session can modify these settings.
Request primary control to change settings.
</div>
</Card>
)}
<Card>
<div className="p-6 space-y-6">
<div className="flex items-center gap-3 mb-4">
<UserGroupIcon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
Access Control
</h3>
</div>
<SettingsItem
title="Require Session Approval"
description="New sessions must be approved by the primary session before gaining access"
>
<Checkbox
checked={requireSessionApproval}
disabled={!canModifySettings}
onChange={e => {
const newValue = e.target.checked;
setRequireSessionApproval(newValue);
updateSessionSettings({ requireApproval: newValue });
notify.success(
newValue
? "New sessions will require approval"
: "New sessions will be automatically approved"
);
}}
/>
</SettingsItem>
<SettingsItem
title="Require Session Nicknames"
description="All sessions must provide a nickname for identification"
>
<Checkbox
checked={requireSessionNickname}
disabled={!canModifySettings}
onChange={e => {
const newValue = e.target.checked;
setRequireSessionNickname(newValue);
updateSessionSettings({ requireNickname: newValue });
notify.success(
newValue
? "Session nicknames are now required"
: "Session nicknames are now optional"
);
}}
/>
</SettingsItem>
<SettingsItem
title="Maximum Rejection Attempts"
description="Number of times a denied session can re-request approval before the modal is hidden"
>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="10"
value={maxRejectionAttempts}
disabled={!canModifySettings}
onChange={e => {
const newValue = parseInt(e.target.value) || 3;
if (newValue < 1 || newValue > 10) {
notify.error("Maximum attempts must be between 1 and 10");
return;
}
setMaxRejectionAttempts(newValue);
updateSessionSettings({ maxRejectionAttempts: newValue });
notify.success(
`Denied sessions can now retry up to ${newValue} time${newValue === 1 ? '' : 's'}`
);
}}
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">attempts</span>
</div>
</SettingsItem>
<SettingsItem
title="Reconnect Grace Period"
description="Time to wait for a session to reconnect before reassigning control"
>
<div className="flex items-center gap-2">
<input
type="number"
min="5"
max="60"
value={reconnectGrace}
disabled={!canModifySettings}
onChange={e => {
const newValue = parseInt(e.target.value) || 10;
if (newValue < 5 || newValue > 60) {
notify.error("Grace period must be between 5 and 60 seconds");
return;
}
setReconnectGrace(newValue);
updateSessionSettings({ reconnectGrace: newValue });
notify.success(
`Session will have ${newValue} seconds to reconnect`
);
}}
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
</div>
</SettingsItem>
<SettingsItem
title="Primary Session Timeout"
description="Time of inactivity before the primary session loses control (0 = disabled)"
>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="3600"
step="60"
value={primaryTimeout}
disabled={!canModifySettings}
onChange={e => {
const newValue = parseInt(e.target.value) || 0;
if (newValue < 0 || newValue > 3600) {
notify.error("Timeout must be between 0 and 3600 seconds");
return;
}
setPrimaryTimeout(newValue);
updateSessionSettings({ primaryTimeout: newValue });
notify.success(
newValue === 0
? "Primary session timeout disabled"
: `Primary session will timeout after ${Math.round(newValue / 60)} minutes of inactivity`
);
}}
className="w-24 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
</div>
</SettingsItem>
<SettingsItem
title="Maximum Concurrent Sessions"
description="Maximum number of sessions that can connect simultaneously"
>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="20"
value={maxSessions}
disabled={!canModifySettings}
onChange={e => {
const newValue = parseInt(e.target.value) || 10;
if (newValue < 1 || newValue > 20) {
notify.error("Max sessions must be between 1 and 20");
return;
}
setMaxSessions(newValue);
updateSessionSettings({ maxSessions: newValue });
notify.success(
`Maximum concurrent sessions set to ${newValue}`
);
}}
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">sessions</span>
</div>
</SettingsItem>
<SettingsItem
title="Observer Cleanup Timeout"
description="Time to wait before cleaning up inactive observer sessions with closed connections"
>
<div className="flex items-center gap-2">
<input
type="number"
min="30"
max="600"
step="30"
value={observerTimeout}
disabled={!canModifySettings}
onChange={e => {
const newValue = parseInt(e.target.value) || 120;
if (newValue < 30 || newValue > 600) {
notify.error("Timeout must be between 30 and 600 seconds");
return;
}
setObserverTimeout(newValue);
updateSessionSettings({ observerTimeout: newValue });
notify.success(
`Observer cleanup timeout set to ${Math.round(newValue / 60)} minute${Math.round(newValue / 60) === 1 ? '' : 's'}`
);
}}
className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">seconds</span>
</div>
</SettingsItem>
<SettingsItem
title="Private Keystrokes"
description="When enabled, only the primary session can see keystroke events"
>
<Checkbox
checked={privateKeystrokes}
disabled={!canModifySettings}
onChange={e => {
const newValue = e.target.checked;
setPrivateKeystrokes(newValue);
updateSessionSettings({ privateKeystrokes: newValue });
notify.success(
newValue
? "Keystrokes are now private to primary session"
: "Keystrokes are visible to all authorized sessions"
);
}}
/>
</SettingsItem>
</div>
</Card>
<Card>
<div className="p-6">
<div className="space-y-4">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
How Multi-Session Access Works
</h3>
<div className="space-y-3 text-sm text-slate-600 dark:text-slate-400">
<div className="flex items-start gap-2">
<span className="font-medium text-slate-700 dark:text-slate-300">Primary:</span>
<span>Full control over the KVM device including keyboard, mouse, and settings</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-slate-700 dark:text-slate-300">Observer:</span>
<span>View-only access to monitor activity without control capabilities</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-slate-700 dark:text-slate-300">Pending:</span>
<span>Awaiting approval from the primary session (when approval is required)</span>
</div>
</div>
<div className="pt-2 text-sm text-slate-500 dark:text-slate-400">
Use the Sessions panel in the top navigation bar to view and manage active sessions.
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router";
import { NavLink, Outlet, useLocation , useNavigate } from "react-router";
import {
LuSettings,
LuMouse,
@ -12,6 +12,7 @@ import {
LuPalette,
LuCommand,
LuNetwork,
LuUsers,
} from "react-icons/lu";
import { useResizeObserver } from "usehooks-ts";
@ -20,11 +21,24 @@ import Card from "@components/Card";
import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag";
import { useUiStore } from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
const location = useLocation();
const navigate = useNavigate();
const { setDisableVideoFocusTrap } = useUiStore();
const { currentMode } = useSessionStore();
const { hasPermission, isLoading, permissions } = usePermissions();
useEffect(() => {
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
navigate("/", { replace: true });
}
}, [permissions, isLoading, currentMode, navigate]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
@ -69,6 +83,21 @@ export default function SettingsRoute() {
};
}, [setDisableVideoFocusTrap]);
// Check permissions first - return early to prevent any content flash
// Show loading state while permissions are being checked
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Checking permissions...</div>
</div>
);
}
// Don't render settings content if user doesn't have permission
if (!hasPermission(Permission.SETTINGS_ACCESS)) {
return null;
}
return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
<div className="h-full">
@ -223,6 +252,17 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="sessions"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuUsers className="h-4 w-4 shrink-0" />
<h1>Multi-Session Access</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"

View File

@ -29,12 +29,17 @@ import {
useNetworkStateStore,
User,
useRTCStore,
useSettingsStore,
useUiStore,
useUpdateStore,
useVideoStore,
VideoState,
} from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo";
import UnifiedSessionRequestDialog from "@components/UnifiedSessionRequestDialog";
import NicknameModal from "@components/NicknameModal";
import AccessDeniedOverlay from "@components/AccessDeniedOverlay";
import PendingApprovalOverlay from "@components/PendingApprovalOverlay";
import DashboardNavbar from "@components/Header";
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
const Terminal = lazy(() => import('@components/Terminal'));
@ -48,8 +53,14 @@ import {
} from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
import { PermissionsProvider } from "@/providers/PermissionsProvider";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion";
import { useSessionManagement } from "@/hooks/useSessionManagement";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@ -122,7 +133,7 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap } = useUiStore();
const [ queryParams, setQueryParams ] = useSearchParams();
const {
@ -141,14 +152,19 @@ export default function KvmIdRoute() {
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const [showNicknameModal, setShowNicknameModal] = useState(false);
const [accessDenied, setAccessDenied] = useState(false);
const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore();
const { currentSessionId, currentMode, setCurrentSession } = useSessionStore();
const { nickname, setNickname } = useSharedSessionStore();
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
setConnectionFailed(true);
if (peerConnection) {
@ -186,7 +202,6 @@ export default function KvmIdRoute() {
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection...");
} catch (error) {
console.error(
@ -204,7 +219,6 @@ export default function KvmIdRoute() {
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
if (pc.sctp?.state === "connected") {
console.log("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval);
setLoadingMessage("Connection established");
} else if (attempts >= 10) {
@ -217,11 +231,6 @@ export default function KvmIdRoute() {
);
cleanupAndStopReconnecting();
clearInterval(checkInterval);
} else {
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
});
}
}, 1000);
},
@ -244,18 +253,15 @@ export default function KvmIdRoute() {
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.debug("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.debug("[Websocket] shouldReconnect", event);
shouldReconnect(_event) {
// TODO: Why true?
return true;
},
onClose(event) {
console.debug("[Websocket] onClose", event);
onClose(_event) {
// We don't want to close everything down, we wait for the reconnect to stop instead
},
@ -264,7 +270,7 @@ export default function KvmIdRoute() {
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.debug("[Websocket] onOpen");
// Connection established, message handling will begin
},
onMessage: message => {
@ -285,27 +291,49 @@ export default function KvmIdRoute() {
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.debug("[Websocket] Received device-metadata message");
console.debug("[Websocket] Device version", deviceVersion);
const { deviceVersion, sessionSettings } = parsedMessage.data;
// Store session settings if provided
if (sessionSettings) {
setGlobalSessionSettings({
requireNickname: sessionSettings.requireNickname || false,
requireApproval: sessionSettings.requireApproval || false
});
// Also update the settings store for approval handling
setRequireSessionApproval(sessionSettings.requireApproval || false);
setRequireSessionNickname(sessionSettings.requireNickname || false);
}
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
// which does everything over HTTP(at least from the perspective of the client)
isLegacySignalingEnabled.current = true;
getWebSocket()?.close();
} else {
console.log("[Websocket] Device is using new signaling");
isLegacySignalingEnabled.current = false;
}
// Always setup peer connection first to establish RPC channel for nickname generation
setupPeerConnection();
// Check if nickname is required and not set - modal will be shown after RPC channel is ready
const requiresNickname = sessionSettings?.requireNickname || false;
if (requiresNickname && !nickname) {
// Store that we need to show the nickname modal once RPC is ready
// The useEffect in NicknameModal will handle waiting for RPC channel readiness
setShowNicknameModal(true);
setDisableVideoFocusTrap(true);
}
}
if (!peerConnection) return;
if (!peerConnection) {
console.warn("[Websocket] Ignoring message because peerConnection is not ready:", parsedMessage.type);
return;
}
if (parsedMessage.type === "answer") {
console.debug("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
@ -319,14 +347,41 @@ export default function KvmIdRoute() {
// Set so we don't accept an answer while we're setting the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.debug(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
const sd = atob(parsedMessage.data);
const remoteSessionDescription = JSON.parse(sd);
if (parsedMessage.sessionId && parsedMessage.mode) {
handleSessionResponse({
sessionId: parsedMessage.sessionId,
mode: parsedMessage.mode
});
// Store sessionId via zustand (persists to sessionStorage for per-tab isolation)
setCurrentSession(parsedMessage.sessionId, parsedMessage.mode);
if (parsedMessage.requireNickname !== undefined && parsedMessage.requireApproval !== undefined) {
setGlobalSessionSettings({
requireNickname: parsedMessage.requireNickname,
requireApproval: parsedMessage.requireApproval
});
// Also update the settings store for approval handling
setRequireSessionApproval(parsedMessage.requireApproval);
setRequireSessionNickname(parsedMessage.requireNickname);
}
// Show nickname modal if:
// 1. Nickname is required by backend settings
// 2. We don't already have a nickname
// This happens even for pending sessions so the nickname is included in approval
const hasNickname = parsedMessage.nickname && parsedMessage.nickname.length > 0;
const requiresNickname = parsedMessage.requireNickname || globalSessionSettings?.requireNickname;
if (requiresNickname && !hasNickname) {
setShowNicknameModal(true);
setDisableVideoFocusTrap(true);
}
}
setRemoteSessionDescription(
peerConnection,
new RTCSessionDescription(remoteSessionDescription),
@ -335,9 +390,29 @@ export default function KvmIdRoute() {
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.debug("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
// Always try to add the ICE candidate - the browser will queue it internally if needed
peerConnection.addIceCandidate(candidate).catch(error => {
console.warn("[Websocket] Failed to add ICE candidate:", error);
});
} else if (parsedMessage.type === "connectionModeChanged") {
// Handle mode changes via WebSocket (fallback when RPC channel stale)
const { newMode, action } = parsedMessage.data;
if (action === "reconnect_required" && newMode) {
console.log(`[Websocket] Mode changed to ${newMode}, reconnecting...`);
if (currentSessionId) {
setCurrentSession(currentSessionId, newMode);
}
handleRpcEvent("connectionModeChanged", parsedMessage.data);
setTimeout(() => {
peerConnection?.close();
setupPeerConnection();
}, 500);
}
}
},
},
@ -350,9 +425,16 @@ export default function KvmIdRoute() {
(type: string, data: unknown) => {
// Second argument tells the library not to queue the message, and send it once the connection is established again.
// We have event handlers that handle the connection set up, so we don't need to queue the message.
sendMessage(JSON.stringify({ type, data }), false);
const message = JSON.stringify({ type, data });
const ws = getWebSocket();
if (ws?.readyState === WebSocket.OPEN) {
sendMessage(message, false);
} else {
console.warn(`[WebSocket] WebSocket not open, queuing message:`, message);
sendMessage(message, true); // Queue the message
}
},
[sendMessage],
[sendMessage, getWebSocket],
);
const legacyHTTPSignaling = useCallback(
@ -363,12 +445,12 @@ export default function KvmIdRoute() {
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
const sessionUrl = `${CLOUD_API}/webrtc/session`;
console.log("Trying to get remote session description");
setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
);
const res = await api.POST(sessionUrl, {
sd,
userAgent: navigator.userAgent,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
});
@ -381,7 +463,6 @@ export default function KvmIdRoute() {
return;
}
console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
@ -392,13 +473,11 @@ export default function KvmIdRoute() {
);
const setupPeerConnection = useCallback(async () => {
console.debug("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
let pc: RTCPeerConnection;
try {
console.debug("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
@ -408,7 +487,6 @@ export default function KvmIdRoute() {
});
setPeerConnectionState(pc.connectionState);
console.debug("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
@ -420,13 +498,11 @@ export default function KvmIdRoute() {
// Set up event listeners and data channels
pc.onconnectionstatechange = () => {
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState);
};
pc.onnegotiationneeded = async () => {
try {
console.debug("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
@ -434,9 +510,19 @@ export default function KvmIdRoute() {
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
if (isNewSignalingEnabled) {
sendWebRTCSignal("offer", { sd: sd });
} else {
console.log("Legacy signaling. Waiting for ICE Gathering to complete...");
// Get nickname and sessionId from zustand stores
// sessionId is per-tab (sessionStorage), nickname is shared (localStorage)
const { currentSessionId: storeSessionId } = useSessionStore.getState();
const { nickname: storeNickname } = useSharedSessionStore.getState();
sendWebRTCSignal("offer", {
sd: sd,
sessionId: storeSessionId || undefined,
userAgent: navigator.userAgent,
sessionSettings: {
nickname: storeNickname || undefined
}
});
}
} catch (e) {
console.error(
@ -450,15 +536,18 @@ export default function KvmIdRoute() {
};
pc.onicecandidate = ({ candidate }) => {
if (!candidate) return;
if (candidate.candidate === "") return;
if (!candidate) {
return;
}
if (candidate.candidate === "") {
return;
}
sendWebRTCSignal("new-ice-candidate", candidate);
};
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.debug("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
@ -466,7 +555,6 @@ export default function KvmIdRoute() {
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
@ -609,42 +697,54 @@ export default function KvmIdRoute() {
const { navigateTo } = useDeviceUiNavigation();
function onJsonRpcRequest(resp: JsonRpcRequest) {
// Handle session-related events
if (resp.method === "sessionsUpdated" ||
resp.method === "modeChanged" ||
resp.method === "connectionModeChanged" ||
resp.method === "otherSessionConnected" ||
resp.method === "primaryControlRequested" ||
resp.method === "primaryControlApproved" ||
resp.method === "primaryControlDenied" ||
resp.method === "newSessionPending" ||
resp.method === "sessionAccessDenied") {
handleRpcEvent(resp.method, resp.params);
// Show access denied overlay if our session was denied
if (resp.method === "sessionAccessDenied") {
setAccessDenied(true);
}
if (resp.method === "otherSessionConnected") {
navigateTo("/other-session");
}
}
if (resp.method === "usbState") {
const usbState = resp.params as unknown as USBStates;
console.debug("Setting USB state", usbState);
setUsbState(usbState);
}
if (resp.method === "videoInputState") {
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
}
if (resp.method === "networkState") {
console.debug("Setting network state", resp.params);
setNetworkState(resp.params as NetworkState);
}
if (resp.method === "keyboardLedState") {
const ledState = resp.params as KeyboardLedState;
console.debug("Setting keyboard led state", ledState);
setKeyboardLedState(ledState);
}
if (resp.method === "keysDownState") {
const downState = resp.params as KeysDownState;
console.debug("Setting key down state:", downState);
setKeysDownState(downState);
}
if (resp.method === "otaState") {
const otaState = resp.params as OtaState;
console.debug("Setting OTA state", otaState);
setOtaState(otaState);
if (otaState.updating === true) {
@ -670,24 +770,38 @@ export default function KvmIdRoute() {
const { send } = useJsonRpc(onJsonRpcRequest);
const {
handleSessionResponse,
handleRpcEvent,
primaryControlRequest,
handleApprovePrimaryRequest,
handleDenyPrimaryRequest,
closePrimaryControlRequest,
newSessionRequest,
handleApproveNewSession,
handleDenyNewSession,
closeNewSessionRequest
} = useSessionManagement(send);
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state");
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
}, [rpcDataChannel?.readyState, hasPermission, isLoadingPermissions, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true);
// request keyboard led state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return;
console.log("Requesting keyboard led state");
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
@ -695,26 +809,22 @@ export default function KvmIdRoute() {
return;
} else {
const ledState = resp.result as KeyboardLedState;
console.debug("Keyboard led state: ", ledState);
setKeyboardLedState(ledState);
}
setNeedLedState(false);
});
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
const [needKeyDownState, setNeedKeyDownState] = useState(true);
// request keyboard key down state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return;
console.log("Requesting keys down state");
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
// -32601 means the method is not supported
if (resp.error.code === RpcMethodNotFound) {
// if we don't support key down state, we know key press is also not available
console.warn("Failed to get key down state, switching to old-school", resp.error);
setHidRpcDisabled(true);
} else {
@ -722,12 +832,11 @@ export default function KvmIdRoute() {
}
} else {
const downState = resp.result as KeysDownState;
console.debug("Keyboard key down state", downState);
setKeysDownState(downState);
}
setNeedKeyDownState(false);
});
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
@ -762,7 +871,8 @@ export default function KvmIdRoute() {
if (appVersion) return;
getLocalVersion();
}, [appVersion, getLocalVersion]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appVersion]);
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
@ -802,6 +912,7 @@ export default function KvmIdRoute() {
]);
return (
<PermissionsProvider>
<FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && (
<AnimatePresence>
@ -840,7 +951,11 @@ export default function KvmIdRoute() {
kvmName={deviceName ?? "JetKVM Device"}
/>
<div className="relative flex h-full w-full overflow-hidden">
{/* Only show video feed if nickname is set (when required) and not pending approval */}
{(!showNicknameModal && currentMode !== "pending") ? (
<>
<WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
@ -850,6 +965,15 @@ export default function KvmIdRoute() {
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
</>
) : (
<div className="flex-1 bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-center">
{showNicknameModal && <p>Please set your nickname to continue</p>}
{currentMode === "pending" && <p>Waiting for session approval...</p>}
</div>
</div>
)}
<SidebarContainer sidebarView={sidebarView} />
</div>
</div>
@ -870,6 +994,27 @@ export default function KvmIdRoute() {
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ setupPeerConnection }} />
</Modal>
<NicknameModal
isOpen={showNicknameModal}
onSubmit={async (nickname) => {
setNickname(nickname);
setShowNicknameModal(false);
setDisableVideoFocusTrap(false);
if (currentSessionId && send) {
try {
await sessionApi.updateNickname(send, currentSessionId, nickname);
} catch (error) {
console.error("Failed to update nickname:", error);
}
}
}}
onSkip={() => {
setShowNicknameModal(false);
setDisableVideoFocusTrap(false);
}}
/>
</div>
{kvmTerminal && (
@ -879,7 +1024,71 @@ export default function KvmIdRoute() {
{serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
)}
{/* Unified Session Request Dialog */}
{(primaryControlRequest || newSessionRequest) && (
<UnifiedSessionRequestDialog
request={
primaryControlRequest
? {
id: primaryControlRequest.requestId,
type: "primary_control",
source: primaryControlRequest.source,
identity: primaryControlRequest.identity,
nickname: primaryControlRequest.nickname,
}
: newSessionRequest
? {
id: newSessionRequest.sessionId,
type: "session_approval",
source: newSessionRequest.source,
identity: newSessionRequest.identity,
nickname: newSessionRequest.nickname,
}
: null
}
onApprove={
primaryControlRequest
? handleApprovePrimaryRequest
: handleApproveNewSession
}
onDeny={
primaryControlRequest
? handleDenyPrimaryRequest
: handleDenyNewSession
}
onDismiss={
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
onClose={
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
/>
)}
<AccessDeniedOverlay
show={accessDenied}
message="Your session access was denied by the primary session"
onRequestApproval={async () => {
if (!send) return;
try {
await sessionApi.requestSessionApproval(send);
setAccessDenied(false);
} catch (error) {
console.error("Failed to re-request approval:", error);
}
}}
/>
<PendingApprovalOverlay
show={currentMode === "pending"}
/>
</FeatureFlagProvider>
</PermissionsProvider>
);
}

View File

@ -0,0 +1,175 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export type SessionMode = "primary" | "observer" | "queued" | "pending";
export interface SessionInfo {
id: string;
mode: SessionMode;
source: "local" | "cloud";
identity?: string;
nickname?: string;
createdAt: string;
lastActive: string;
}
export interface SessionState {
// Current session info
currentSessionId: string | null;
currentMode: SessionMode | null;
// All active sessions
sessions: SessionInfo[];
// UI state
isRequestingPrimary: boolean;
sessionError: string | null;
rejectionCount: number;
// Actions
setCurrentSession: (id: string, mode: SessionMode) => void;
setSessions: (sessions: SessionInfo[]) => void;
setRequestingPrimary: (requesting: boolean) => void;
setSessionError: (error: string | null) => void;
updateSessionMode: (mode: SessionMode) => void;
clearSession: () => void;
incrementRejectionCount: () => number;
resetRejectionCount: () => void;
// Computed getters
isPrimary: () => boolean;
isObserver: () => boolean;
isQueued: () => boolean;
isPending: () => boolean;
canRequestPrimary: () => boolean;
getPrimarySession: () => SessionInfo | undefined;
getQueuePosition: () => number;
}
export const useSessionStore = create<SessionState>()(
persist(
(set, get) => ({
// Initial state
currentSessionId: null,
currentMode: null,
sessions: [],
isRequestingPrimary: false,
sessionError: null,
rejectionCount: 0,
// Actions
setCurrentSession: (id: string, mode: SessionMode) => {
set({
currentSessionId: id,
currentMode: mode,
sessionError: null
});
},
setSessions: (sessions: SessionInfo[]) => {
set({ sessions });
},
setRequestingPrimary: (requesting: boolean) => {
set({ isRequestingPrimary: requesting });
},
setSessionError: (error: string | null) => {
set({ sessionError: error });
},
updateSessionMode: (mode: SessionMode) => {
set({ currentMode: mode });
},
clearSession: () => {
set({
currentSessionId: null,
currentMode: null,
sessions: [],
sessionError: null,
isRequestingPrimary: false,
rejectionCount: 0
});
},
incrementRejectionCount: () => {
const newCount = get().rejectionCount + 1;
set({ rejectionCount: newCount });
return newCount;
},
resetRejectionCount: () => {
set({ rejectionCount: 0 });
},
// Computed getters
isPrimary: () => {
return get().currentMode === "primary";
},
isObserver: () => {
return get().currentMode === "observer";
},
isQueued: () => {
return get().currentMode === "queued";
},
isPending: () => {
return get().currentMode === "pending";
},
canRequestPrimary: () => {
const state = get();
return state.currentMode === "observer" &&
!state.isRequestingPrimary &&
state.sessions.some(s => s.mode === "primary");
},
getPrimarySession: () => {
return get().sessions.find(s => s.mode === "primary");
},
getQueuePosition: () => {
const state = get();
if (state.currentMode !== "queued") return -1;
const queuedSessions = state.sessions
.filter(s => s.mode === "queued")
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
return queuedSessions.findIndex(s => s.id === state.currentSessionId) + 1;
}
}),
{
name: 'session',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
currentSessionId: state.currentSessionId,
}),
}
)
);
// Shared session store - separate with localStorage (shared across tabs)
// Used for user preferences that should be consistent across all tabs
export interface SharedSessionState {
nickname: string | null;
setNickname: (nickname: string | null) => void;
clearNickname: () => void;
}
export const useSharedSessionStore = create<SharedSessionState>()(
persist(
(set) => ({
nickname: null,
setNickname: (nickname: string | null) => set({ nickname }),
clearNickname: () => set({ nickname: null }),
}),
{
name: 'sharedSession',
storage: createJSONStorage(() => localStorage),
}
)
);

View File

@ -0,0 +1,30 @@
export enum Permission {
VIDEO_VIEW = "video.view",
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
SESSION_TRANSFER = "session.transfer",
SESSION_APPROVE = "session.approve",
SESSION_KICK = "session.kick",
SESSION_REQUEST_PRIMARY = "session.request_primary",
SESSION_RELEASE_PRIMARY = "session.release_primary",
SESSION_MANAGE = "session.manage",
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}

View File

@ -0,0 +1,36 @@
// Nickname generation using backend API for consistency
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
// Main function that uses backend generation
export async function generateNickname(sendFn?: RpcSendFunction): Promise<string> {
// Require backend function - no fallback
if (!sendFn) {
throw new Error('Backend connection required for nickname generation');
}
return new Promise((resolve, reject) => {
try {
const result = sendFn('generateNickname', { userAgent: navigator.userAgent }, (response: { result?: unknown; error?: { message: string } }) => {
const result = response.result as { nickname?: string } | undefined;
if (response && !response.error && result?.nickname) {
resolve(result.nickname);
} else {
reject(new Error('Failed to generate nickname from backend'));
}
});
// If sendFn returns undefined (RPC channel not ready), reject immediately
if (result === undefined) {
reject(new Error('RPC connection not ready yet'));
}
} catch (error) {
reject(error);
}
});
}
// Synchronous version removed - backend generation is always async
export function generateNicknameSync(): string {
throw new Error('Synchronous nickname generation not supported - use backend generateNickname()');
}

107
usb.go
View File

@ -27,20 +27,43 @@ func initUsbGadget() {
}()
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
if currentSession != nil {
currentSession.reportHidRPCKeyboardLedState(state)
// Check if keystrokes should be private
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
// Report to primary session only
if primary := sessionManager.GetPrimarySession(); primary != nil {
primary.reportHidRPCKeyboardLedState(state)
}
} else {
// Report to all authorized sessions (primary and observers, but not pending)
sessionManager.ForEachSession(func(s *Session) {
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
s.reportHidRPCKeyboardLedState(state)
}
})
}
})
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
if currentSession != nil {
currentSession.enqueueKeysDownState(state)
// Check if keystrokes should be private
if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes {
// Report to primary session only
if primary := sessionManager.GetPrimarySession(); primary != nil {
primary.enqueueKeysDownState(state)
}
} else {
// Report to all authorized sessions (primary and observers, but not pending)
sessionManager.ForEachSession(func(s *Session) {
if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver {
s.enqueueKeysDownState(state)
}
})
}
})
gadget.SetOnKeepAliveReset(func() {
if currentSession != nil {
currentSession.resetKeepAliveTime()
// Reset keep-alive for primary session
if primary := sessionManager.GetPrimarySession(); primary != nil {
primary.resetKeepAliveTime()
}
})
@ -50,26 +73,82 @@ func initUsbGadget() {
}
}
func rpcKeyboardReport(modifier byte, keys []byte) error {
func (s *Session) rpcKeyboardReport(modifier byte, keys []byte) error {
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
return ErrPermissionDeniedKeyboard
}
sessionManager.UpdateLastActive(s.ID)
return gadget.KeyboardReport(modifier, keys)
}
func rpcKeypressReport(key byte, press bool) error {
func (s *Session) rpcKeypressReport(key byte, press bool) error {
if s == nil || !s.HasPermission(PermissionKeyboardInput) {
return ErrPermissionDeniedKeyboard
}
sessionManager.UpdateLastActive(s.ID)
return gadget.KeypressReport(key, press)
}
func rpcAbsMouseReport(x int, y int, buttons uint8) error {
func (s *Session) rpcAbsMouseReport(x int16, y int16, buttons uint8) error {
if s == nil || !s.HasPermission(PermissionMouseInput) {
return ErrPermissionDeniedMouse
}
sessionManager.UpdateLastActive(s.ID)
return gadget.AbsMouseReport(x, y, buttons)
}
func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
func (s *Session) rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
if s == nil || !s.HasPermission(PermissionMouseInput) {
return ErrPermissionDeniedMouse
}
sessionManager.UpdateLastActive(s.ID)
return gadget.RelMouseReport(dx, dy, buttons)
}
func rpcWheelReport(wheelY int8) error {
func (s *Session) rpcWheelReport(wheelY int8) error {
if s == nil || !s.HasPermission(PermissionMouseInput) {
return ErrPermissionDeniedMouse
}
sessionManager.UpdateLastActive(s.ID)
return gadget.AbsMouseWheelReport(wheelY)
}
// RPC functions that route to the primary session
func rpcKeyboardReport(modifier byte, keys []byte) error {
if primary := sessionManager.GetPrimarySession(); primary != nil {
return primary.rpcKeyboardReport(modifier, keys)
}
return ErrNotPrimarySession
}
func rpcKeypressReport(key byte, press bool) error {
if primary := sessionManager.GetPrimarySession(); primary != nil {
return primary.rpcKeypressReport(key, press)
}
return ErrNotPrimarySession
}
func rpcAbsMouseReport(x int16, y int16, buttons uint8) error {
if primary := sessionManager.GetPrimarySession(); primary != nil {
return primary.rpcAbsMouseReport(x, y, buttons)
}
return ErrNotPrimarySession
}
func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error {
if primary := sessionManager.GetPrimarySession(); primary != nil {
return primary.rpcRelMouseReport(dx, dy, buttons)
}
return ErrNotPrimarySession
}
func rpcWheelReport(wheelY int8) error {
if primary := sessionManager.GetPrimarySession(); primary != nil {
return primary.rpcWheelReport(wheelY)
}
return ErrNotPrimarySession
}
func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
return gadget.GetKeyboardState()
}
@ -89,11 +168,7 @@ func rpcGetUSBState() (state string) {
func triggerUSBStateUpdate() {
go func() {
if currentSession == nil {
usbLogger.Info().Msg("No active RPC session, skipping USB state update")
return
}
writeJSONRPCEvent("usbState", usbState, currentSession)
broadcastJSONRPCEvent("usbState", usbState)
}()
}

View File

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

196
web.go
View File

@ -35,9 +35,24 @@ var staticFiles embed.FS
type WebRTCSessionRequest struct {
Sd string `json:"sd"`
SessionId string `json:"sessionId,omitempty"`
OidcGoogle string `json:"OidcGoogle,omitempty"`
IP string `json:"ip,omitempty"`
ICEServers []string `json:"iceServers,omitempty"`
UserAgent string `json:"userAgent,omitempty"` // Browser user agent for nickname generation
SessionSettings *SessionSettings `json:"sessionSettings,omitempty"`
}
type SessionSettings struct {
RequireApproval bool `json:"requireApproval"`
RequireNickname bool `json:"requireNickname"`
ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection
PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session
Nickname string `json:"nickname,omitempty"`
PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events
MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"` // Number of times denied session can retry before modal hides
MaxSessions int `json:"maxSessions,omitempty"` // Maximum number of concurrent sessions (default: 10)
ObserverTimeout int `json:"observerTimeout,omitempty"` // Time in seconds to wait before cleaning up inactive observer sessions (default: 120)
}
type SetPasswordRequest struct {
@ -158,32 +173,16 @@ func setupRouter() *gin.Engine {
protected := r.Group("/")
protected.Use(protectedMiddleware())
{
/*
* Legacy WebRTC session endpoint
*
* This endpoint is maintained for backward compatibility when users upgrade from a version
* using the legacy HTTP-based signaling method to the new WebSocket-based signaling method.
*
* During the upgrade process, when the "Rebooting device after update..." message appears,
* the browser still runs the previous JavaScript code which polls this endpoint to establish
* a new WebRTC session. Once the session is established, the page will automatically reload
* with the updated code.
*
* Without this endpoint, the stale JavaScript would fail to establish a connection,
* causing users to see the "Rebooting device after update..." message indefinitely
* until they manually refresh the page, leading to a confusing user experience.
*/
protected.POST("/webrtc/session", handleWebRTCSession)
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
protected.POST("/cloud/register", handleCloudRegister)
protected.GET("/cloud/state", handleCloudState)
protected.GET("/device", handleDevice)
protected.POST("/auth/logout", handleLogout)
protected.POST("/auth/password-local", handleCreatePassword)
protected.PUT("/auth/password-local", handleUpdatePassword)
protected.DELETE("/auth/local-password", handleDeletePassword)
protected.POST("/storage/upload", handleUploadHttp)
protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword)
protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword)
protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword)
protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp)
}
// Catch-all route for SPA
@ -198,44 +197,6 @@ func setupRouter() *gin.Engine {
return r
}
// TODO: support multiple sessions?
var currentSession *Session
func handleWebRTCSession(c *gin.Context) {
var req WebRTCSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, err := newSession(SessionConfig{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
sd, err := session.ExchangeOffer(req.Sd)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
if currentSession != nil {
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection
go func() {
time.Sleep(1 * time.Second)
_ = peerConn.Close()
}()
}
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd})
}
var (
pingMessage = []byte("ping")
pongMessage = []byte("pong")
@ -244,7 +205,15 @@ var (
func handleLocalWebRTCSignal(c *gin.Context) {
// get the source from the request
source := c.ClientIP()
connectionID := uuid.New().String()
// Try to get existing session ID from cookie for session persistence
sessionID, _ := c.Cookie("sessionId")
if sessionID == "" {
sessionID = uuid.New().String()
// Set session ID cookie with same expiry as auth token (7 days)
c.SetCookie("sessionId", sessionID, 7*24*60*60, "/", "", false, true)
}
connectionID := sessionID
scopedLogger := websocketLogger.With().
Str("component", "websocket").
@ -276,7 +245,17 @@ func handleLocalWebRTCSignal(c *gin.Context) {
// Now use conn for websocket operations
defer wsCon.Close(websocket.StatusNormalClosure, "")
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}})
// Include session settings in device metadata so client knows requirements upfront
sessionSettingsData := gin.H{
"deviceVersion": builtAppVersion,
}
if currentSessionSettings != nil {
sessionSettingsData["sessionSettings"] = gin.H{
"requireNickname": currentSessionSettings.RequireNickname,
"requireApproval": currentSessionSettings.RequireApproval,
}
}
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": sessionSettingsData})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@ -380,6 +359,13 @@ func handleWebRTCSignalWsMessages(
typ, msg, err := wsCon.Read(runCtx)
if err != nil {
l.Warn().Str("error", err.Error()).Msg("websocket read error")
// Clean up session when websocket closes
if session := sessionManager.GetSession(connectionID); session != nil && session.peerConnection != nil {
l.Info().
Str("sessionID", session.ID).
Msg("Closing peer connection due to websocket error")
_ = session.peerConnection.Close()
}
return err
}
if typ != websocket.MessageText {
@ -412,14 +398,17 @@ func handleWebRTCSignalWsMessages(
continue
}
l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message")
if message.Type == "offer" {
l.Info().Msg("new session request received")
l.Info().Str("dataRaw", string(message.Data)).Msg("new session request received with raw data")
var req WebRTCSessionRequest
err = json.Unmarshal(message.Data, &req)
if err != nil {
l.Warn().Str("error", err.Error()).Msg("unable to parse session request data")
l.Warn().Str("error", err.Error()).Str("dataRaw", string(message.Data)).Msg("unable to parse session request data")
continue
}
l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request")
if req.OidcGoogle != "" {
l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google")
@ -427,7 +416,7 @@ func handleWebRTCSignalWsMessages(
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l)
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, connectionID, &l)
if err != nil {
l.Warn().Str("error", err.Error()).Msg("error starting new session")
continue
@ -449,14 +438,16 @@ func handleWebRTCSignalWsMessages(
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
if currentSession == nil {
l.Warn().Msg("no current session, skipping incoming ICE candidate")
// Find the session this ICE candidate belongs to using the connectionID
session := sessionManager.GetSession(connectionID)
if session == nil {
l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate")
continue
}
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session")
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection")
l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session")
if err = session.peerConnection.AddICECandidate(candidate); err != nil {
l.Warn().Str("error", err.Error()).Str("sessionID", session.ID).Msg("failed to add incoming ICE candidate to peer connection")
}
}
}
@ -481,7 +472,16 @@ func handleLogin(c *gin.Context) {
return
}
// Don't generate a new token - use the existing one
// This ensures all sessions can share the same auth token
if config.LocalAuthToken == "" {
// Only generate if we don't have one (shouldn't happen in normal operation)
config.LocalAuthToken = uuid.New().String()
if err := SaveConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
return
}
}
// Set the cookie
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
@ -490,14 +490,30 @@ func handleLogin(c *gin.Context) {
}
func handleLogout(c *gin.Context) {
config.LocalAuthToken = ""
if err := SaveConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
return
// Get session ID from cookie before clearing
sessionID, _ := c.Cookie("sessionId")
// Close the WebRTC session immediately for intentional logout
if sessionID != "" {
if session := sessionManager.GetSession(sessionID); session != nil {
websocketLogger.Info().
Str("sessionID", sessionID).
Msg("Closing session due to intentional logout - no grace period")
// Close peer connection (will trigger cleanupSession)
if session.peerConnection != nil {
_ = session.peerConnection.Close()
}
// Clear the auth cookie
// Clear grace period for intentional logout - observer should be promoted immediately
sessionManager.ClearGracePeriod(sessionID)
}
}
// Clear the cookies for this session, don't invalidate the token
// The token should remain valid for other sessions
c.SetCookie("authToken", "", -1, "/", "", false, true)
c.SetCookie("sessionId", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
}
@ -519,6 +535,38 @@ func protectedMiddleware() gin.HandlerFunc {
}
}
// requirePermissionMiddleware creates a middleware that enforces specific permissions
func requirePermissionMiddleware(permission Permission) gin.HandlerFunc {
return func(c *gin.Context) {
// Get session ID from cookie
sessionID, err := c.Cookie("sessionId")
if err != nil || sessionID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "No session ID found"})
c.Abort()
return
}
// Get session from manager
session := sessionManager.GetSession(sessionID)
if session == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session not found"})
c.Abort()
return
}
// Check permission
if !session.HasPermission(permission) {
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("Permission denied: %s required", permission)})
c.Abort()
return
}
// Store session in context for use by handlers
c.Set("session", session)
c.Next()
}
}
func sendErrorJsonThenAbort(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
c.Abort()
@ -591,7 +639,7 @@ func RunWebServer() {
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
if err := r.Run(bindAddress); err != nil {
panic(err)
logger.Fatal().Err(err).Msg("failed to start web server")
}
}

View File

@ -184,7 +184,7 @@ func runWebSecureServer() {
err := server.ListenAndServeTLS("", "")
if !errors.Is(err, http.ErrServerClosed) {
panic(err)
websecureLogger.Fatal().Err(err).Msg("failed to start websecure server")
}
}

253
webrtc.go
View File

@ -19,14 +19,40 @@ import (
"github.com/rs/zerolog"
)
// Predefined browser string constants for memory efficiency
var (
BrowserChrome = "chrome"
BrowserFirefox = "firefox"
BrowserSafari = "safari"
BrowserEdge = "edge"
BrowserOpera = "opera"
BrowserUnknown = "user"
)
type Session struct {
ID string
Mode SessionMode
Source string
Identity string
Nickname string
Browser *string // Pointer to predefined browser string constant for memory efficiency
CreatedAt time.Time
LastActive time.Time
LastBroadcast time.Time // Per-session broadcast throttle
// RPC rate limiting (DoS protection)
rpcRateLimitMu sync.Mutex // Protects rate limit fields
rpcRateLimit int // Count of RPCs in current window
rpcRateLimitWin time.Time // Start of current rate limit window
peerConnection *webrtc.PeerConnection
VideoTrack *webrtc.TrackLocalStaticSample
ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
shouldUmountVirtualMedia bool
flushCandidates func() // Callback to flush buffered ICE candidates
ws *websocket.Conn // WebSocket for critical signaling when RPC unavailable
rpcQueue chan webrtc.DataChannelMessage
hidRPCAvailable bool
@ -52,14 +78,6 @@ func incrActiveSessions() int {
return actionSessions
}
func decrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions--
return actionSessions
}
func getActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
@ -67,6 +85,30 @@ func getActiveSessions() int {
return actionSessions
}
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
func (s *Session) CheckRPCRateLimit() bool {
const (
maxRPCPerSecond = 500 // Increased to support 10+ concurrent sessions with broadcasts and state updates
rateLimitWindow = time.Second
)
s.rpcRateLimitMu.Lock()
defer s.rpcRateLimitMu.Unlock()
now := time.Now()
// Reset window if it has expired
if now.Sub(s.rpcRateLimitWin) > rateLimitWindow {
s.rpcRateLimit = 0
s.rpcRateLimitWin = now
}
s.rpcRateLimit++
if s.rpcRateLimit > maxRPCPerSecond {
return false // Rate limit exceeded
}
return true // Within limits
}
func (s *Session) resetKeepAliveTime() {
s.keepAliveJitterLock.Lock()
defer s.keepAliveJitterLock.Unlock()
@ -74,6 +116,22 @@ func (s *Session) resetKeepAliveTime() {
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
}
// sendWebSocketSignal sends critical state changes via WebSocket (fallback when RPC channel stale)
func (s *Session) sendWebSocketSignal(messageType string, data map[string]interface{}) error {
if s == nil || s.ws == nil {
return nil
}
err := wsjson.Write(context.Background(), s.ws, gin.H{"type": messageType, "data": data})
if err != nil {
webrtcLogger.Debug().Err(err).Str("sessionId", s.ID).Msg("Failed to send WebSocket signal")
return err
}
webrtcLogger.Info().Str("sessionId", s.ID).Str("messageType", messageType).Msg("Sent WebSocket signal")
return nil
}
type hidQueueMessage struct {
webrtc.DataChannelMessage
channel string
@ -83,6 +141,7 @@ type SessionConfig struct {
ICEServers []string
LocalIP string
IsCloud bool
UserAgent string // User agent for browser detection and nickname generation
ws *websocket.Conn
Logger *zerolog.Logger
}
@ -134,8 +193,15 @@ func (s *Session) initQueues() {
func (s *Session) handleQueues(index int) {
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)
}
}
}
const keysDownStateQueueSize = 64
@ -246,7 +312,11 @@ func newSession(config SessionConfig) (*Session, error) {
return nil, err
}
session := &Session{peerConnection: peerConnection}
session := &Session{
peerConnection: peerConnection,
Browser: extractBrowserFromUserAgent(config.UserAgent),
ws: config.ws,
}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues()
session.initKeysDownStateQueue()
@ -254,7 +324,16 @@ func newSession(config SessionConfig) (*Session, error) {
go func() {
for msg := range session.rpcQueue {
// TODO: only use goroutine if the task is asynchronous
go onRPCMessage(msg, session)
go func(m webrtc.DataChannelMessage) {
// Get current session from manager to ensure we have the latest state
currentSession := sessionManager.GetSession(session.ID)
if currentSession != nil {
onRPCMessage(m, currentSession)
} else {
// Session was removed, use original to avoid nil panic
onRPCMessage(m, session)
}
}(msg)
}
}()
@ -290,9 +369,9 @@ func newSession(config SessionConfig) (*Session, error) {
triggerVideoStateUpdate()
triggerUSBStateUpdate()
case "terminal":
handleTerminalChannel(d)
handleTerminalChannel(d, session)
case "serial":
handleSerialChannel(d)
handleSerialChannel(d, session)
default:
if strings.HasPrefix(d.Label(), uploadIdPrefix) {
go handleUploadChannel(d)
@ -325,9 +404,23 @@ func newSession(config SessionConfig) (*Session, error) {
}()
var isConnected bool
// Buffer to hold ICE candidates until answer is sent
var candidateBuffer []webrtc.ICECandidateInit
var candidateBufferMutex sync.Mutex
var answerSent bool
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
if candidate != nil {
candidateBufferMutex.Lock()
if !answerSent {
// Buffer the candidate until answer is sent
candidateBuffer = append(candidateBuffer, candidate.ToJSON())
candidateBufferMutex.Unlock()
return
}
candidateBufferMutex.Unlock()
err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()})
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
@ -335,8 +428,88 @@ func newSession(config SessionConfig) (*Session, error) {
}
})
// Store the callback to flush buffered candidates
session.flushCandidates = func() {
candidateBufferMutex.Lock()
answerSent = true
// Send all buffered candidates
for _, candidate := range candidateBuffer {
err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate})
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to write buffered new-ice-candidate to WebRTC signaling channel")
}
}
candidateBuffer = nil
candidateBufferMutex.Unlock()
}
// Track cleanup state to prevent double cleanup
var cleanedUp bool
var cleanupMutex sync.Mutex
cleanupSession := func(reason string) {
cleanupMutex.Lock()
defer cleanupMutex.Unlock()
if cleanedUp {
return
}
cleanedUp = true
scopedLogger.Info().
Str("sessionID", session.ID).
Str("reason", reason).
Msg("Cleaning up session")
// Remove from session manager
sessionManager.RemoveSession(session.ID)
// Cancel any ongoing keyboard macro if session has permission
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()
}
// Stop RPC processor
if session.rpcQueue != nil {
close(session.rpcQueue)
session.rpcQueue = nil
}
// Stop HID RPC processor
for i := 0; i < len(session.hidQueue); i++ {
if session.hidQueue[i] != nil {
close(session.hidQueue[i])
session.hidQueue[i] = nil
}
}
if session.keysDownStateQueue != nil {
close(session.keysDownStateQueue)
session.keysDownStateQueue = nil
}
if session.shouldUmountVirtualMedia {
if err := rpcUnmountImage(); err != nil {
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
}
}
if isConnected {
isConnected = false
actionSessions--
onActiveSessionsChanged()
if actionSessions == 0 {
onLastSessionDisconnected()
}
}
}
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed")
scopedLogger.Info().
Str("sessionID", session.ID).
Str("connectionState", connectionState.String()).
Msg("ICE Connection State has changed")
if connectionState == webrtc.ICEConnectionStateConnected {
if !isConnected {
isConnected = true
@ -346,45 +519,27 @@ func newSession(config SessionConfig) (*Session, error) {
}
}
}
//state changes on closing browser tab disconnected->failed, we need to manually close it
// Handle disconnection and failure states
if connectionState == webrtc.ICEConnectionStateDisconnected {
scopedLogger.Info().
Str("sessionID", session.ID).
Msg("ICE Connection State is disconnected, connection may recover")
}
if connectionState == webrtc.ICEConnectionStateFailed {
scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection")
scopedLogger.Info().
Str("sessionID", session.ID).
Msg("ICE Connection State is failed, closing peerConnection and cleaning up")
cleanupSession("ice-failed")
_ = peerConnection.Close()
}
if connectionState == webrtc.ICEConnectionStateClosed {
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
if session == currentSession {
// Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro()
currentSession = nil
}
// Stop RPC processor
if session.rpcQueue != nil {
close(session.rpcQueue)
session.rpcQueue = nil
}
// Stop HID RPC processor
for i := 0; i < len(session.hidQueue); i++ {
close(session.hidQueue[i])
session.hidQueue[i] = nil
}
close(session.keysDownStateQueue)
session.keysDownStateQueue = nil
if session.shouldUmountVirtualMedia {
if err := rpcUnmountImage(); err != nil {
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
}
}
if isConnected {
isConnected = false
onActiveSessionsChanged()
if decrActiveSessions() == 0 {
onLastSessionDisconnected()
}
}
scopedLogger.Info().
Str("sessionID", session.ID).
Msg("ICE Connection State is closed, cleaning up")
cleanupSession("ice-closed")
}
})
return session, nil