From cd70efb83f6571d32e1c4e354b7d0090218dac87 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 17:45:37 +0300 Subject: [PATCH 1/7] 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(-) --- cloud.go | 96 +- config.go | 24 + datachannel_helpers.go | 11 + errors.go | 10 + hidrpc.go | 20 +- internal/session/permissions.go | 306 +++ internal/session/types.go | 11 + internal/usbgadget/hid_keyboard.go | 2 +- internal/usbgadget/hid_mouse_absolute.go | 2 +- jiggler.go | 5 +- jsonrpc.go | 351 +++- main.go | 15 +- native.go | 19 +- network.go | 7 +- ota.go | 6 +- serial.go | 25 +- session_manager.go | 1633 +++++++++++++++++ session_permissions.go | 77 + terminal.go | 11 +- ui/src/api/sessionApi.ts | 113 ++ ui/src/components/AccessDeniedOverlay.tsx | 117 ++ ui/src/components/ActionBar.tsx | 361 ++-- ui/src/components/Header.tsx | 8 +- ui/src/components/MacroBar.tsx | 12 +- ui/src/components/NicknameModal.tsx | 262 +++ ui/src/components/PendingApprovalOverlay.tsx | 53 + ui/src/components/SessionControlPanel.tsx | 139 ++ ui/src/components/SessionsList.tsx | 149 ++ .../UnifiedSessionRequestDialog.tsx | 243 +++ ui/src/components/WebRTCVideo.tsx | 65 +- ui/src/components/popovers/SessionPopover.tsx | 207 +++ ui/src/hooks/stores.ts | 12 + ui/src/hooks/useJsonRpc.ts | 12 +- ui/src/hooks/usePermissions.ts | 164 ++ ui/src/hooks/useSessionEvents.ts | 153 ++ ui/src/hooks/useSessionManagement.ts | 177 ++ ui/src/main.tsx | 9 + ui/src/notifications.tsx | 42 +- .../devices.$id.settings.access._index.tsx | 2 + .../routes/devices.$id.settings.hardware.tsx | 44 +- .../devices.$id.settings.multi-session.tsx | 262 +++ ui/src/routes/devices.$id.settings.tsx | 40 + ui/src/routes/devices.$id.tsx | 344 +++- ui/src/stores/sessionStore.ts | 160 ++ ui/src/utils/nicknameGenerator.ts | 33 + usb.go | 107 +- video.go | 2 +- web.go | 172 +- web_tls.go | 2 +- webrtc.go | 229 ++- 50 files changed, 5884 insertions(+), 442 deletions(-) create mode 100644 datachannel_helpers.go create mode 100644 errors.go create mode 100644 internal/session/permissions.go create mode 100644 internal/session/types.go create mode 100644 session_manager.go create mode 100644 session_permissions.go create mode 100644 ui/src/api/sessionApi.ts create mode 100644 ui/src/components/AccessDeniedOverlay.tsx create mode 100644 ui/src/components/NicknameModal.tsx create mode 100644 ui/src/components/PendingApprovalOverlay.tsx create mode 100644 ui/src/components/SessionControlPanel.tsx create mode 100644 ui/src/components/SessionsList.tsx create mode 100644 ui/src/components/UnifiedSessionRequestDialog.tsx create mode 100644 ui/src/components/popovers/SessionPopover.tsx create mode 100644 ui/src/hooks/usePermissions.ts create mode 100644 ui/src/hooks/useSessionEvents.ts create mode 100644 ui/src/hooks/useSessionManagement.ts create mode 100644 ui/src/routes/devices.$id.settings.multi-session.tsx create mode 100644 ui/src/stores/sessionStore.ts create mode 100644 ui/src/utils/nicknameGenerator.ts diff --git a/cloud.go b/cloud.go index a851d51f..33fa7377 100644 --- a/cloud.go +++ b/cloud.go @@ -197,6 +197,20 @@ func wsResetMetrics(established bool, sourceType string, source string) { } func handleCloudRegister(c *gin.Context) { + sessionID, _ := c.Cookie("sessionId") + authToken, _ := c.Cookie("authToken") + + if sessionID != "" && authToken != "" && authToken == config.LocalAuthToken { + session := sessionManager.GetSession(sessionID) + if session != nil && !session.HasPermission(PermissionSettingsWrite) { + c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"}) + return + } + } else if sessionID != "" { + c.JSON(401, gin.H{"error": "Authentication required"}) + return + } + var req CloudRegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -426,8 +440,15 @@ func handleSessionRequest( req WebRTCSessionRequest, isCloudConnection bool, source string, + connectionID string, scopedLogger *zerolog.Logger, -) error { +) (returnErr error) { + defer func() { + if r := recover(); r != nil { + websocketLogger.Error().Interface("panic", r).Msg("PANIC in handleSessionRequest") + returnErr = fmt.Errorf("panic: %v", r) + } + }() var sourceType string if isCloudConnection { sourceType = "cloud" @@ -453,6 +474,7 @@ func handleSessionRequest( IsCloud: isCloudConnection, LocalIP: req.IP, ICEServers: req.ICEServers, + UserAgent: req.UserAgent, Logger: scopedLogger, }) if err != nil { @@ -462,26 +484,72 @@ func handleSessionRequest( sd, err := session.ExchangeOffer(req.Sd) if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to exchange offer") _ = wsjson.Write(context.Background(), c, gin.H{"error": err}) return err } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() + session.Source = source + + if isCloudConnection && req.OidcGoogle != "" { + session.Identity = config.GoogleIdentity + + // Use client-provided sessionId for reconnection, otherwise generate new one + // This enables multi-tab support while preserving reconnection on refresh + if req.SessionId != "" { + session.ID = req.SessionId + scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID") + } else { + session.ID = connectionID + scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established") + } + } else { + session.ID = connectionID + scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established") } - cloudLogger.Info().Interface("session", session).Msg("new session accepted") - cloudLogger.Trace().Interface("session", session).Msg("new session accepted") + if sessionManager == nil { + scopedLogger.Error().Msg("sessionManager is nil") + _ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"}) + return fmt.Errorf("session manager not initialized") + } + err = sessionManager.AddSession(session, req.SessionSettings) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to add session to session manager") + if err == ErrMaxSessionsReached { + _ = wsjson.Write(context.Background(), c, gin.H{"error": "maximum sessions reached"}) + } else { + _ = wsjson.Write(context.Background(), c, gin.H{"error": err.Error()}) + } + return err + } - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() + if session.HasPermission(PermissionPaste) { + cancelKeyboardMacro() + } - currentSession = session - _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) + requireNickname := false + requireApproval := false + if currentSessionSettings != nil { + requireNickname = currentSessionSettings.RequireNickname + requireApproval = currentSessionSettings.RequireApproval + } + + err = wsjson.Write(context.Background(), c, gin.H{ + "type": "answer", + "data": sd, + "sessionId": session.ID, + "mode": session.Mode, + "nickname": session.Nickname, + "requireNickname": requireNickname, + "requireApproval": requireApproval, + }) + if err != nil { + return err + } + + if session.flushCandidates != nil { + session.flushCandidates() + } return nil } diff --git a/config.go b/config.go index c83ccfc7..bc8b463d 100644 --- a/config.go +++ b/config.go @@ -77,11 +77,21 @@ func (m *KeyboardMacro) Validate() error { return nil } +// MultiSessionConfig defines settings for multi-session support +type MultiSessionConfig struct { + Enabled bool `json:"enabled"` + MaxSessions int `json:"max_sessions"` + PrimaryTimeout int `json:"primary_timeout_seconds"` + AllowCloudOverride bool `json:"allow_cloud_override"` + RequireAuthTransfer bool `json:"require_auth_transfer"` +} + type Config struct { CloudURL string `json:"cloud_url"` CloudAppURL string `json:"cloud_app_url"` CloudToken string `json:"cloud_token"` GoogleIdentity string `json:"google_identity"` + MultiSession *MultiSessionConfig `json:"multi_session"` JigglerEnabled bool `json:"jiggler_enabled"` JigglerConfig *JigglerConfig `json:"jiggler_config"` AutoUpdateEnabled bool `json:"auto_update_enabled"` @@ -104,6 +114,7 @@ type Config struct { UsbDevices *usbgadget.Devices `json:"usb_devices"` NetworkConfig *network.NetworkConfig `json:"network_config"` DefaultLogLevel string `json:"default_log_level"` + SessionSettings *SessionSettings `json:"session_settings"` } func (c *Config) GetDisplayRotation() uint16 { @@ -132,12 +143,25 @@ var defaultConfig = &Config{ CloudAppURL: "https://app.jetkvm.com", AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", + MultiSession: &MultiSessionConfig{ + Enabled: true, // Enable by default for new features + MaxSessions: 10, // Reasonable default + PrimaryTimeout: 300, // 5 minutes + AllowCloudOverride: true, // Cloud sessions can take control + RequireAuthTransfer: false, // Don't require auth by default + }, KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes + SessionSettings: &SessionSettings{ + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, // 10 seconds default + PrivateKeystrokes: false, // By default, share keystrokes with observers + }, JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ diff --git a/datachannel_helpers.go b/datachannel_helpers.go new file mode 100644 index 00000000..dd1ad2da --- /dev/null +++ b/datachannel_helpers.go @@ -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) {}) +} diff --git a/errors.go b/errors.go new file mode 100644 index 00000000..b1d9f698 --- /dev/null +++ b/errors.go @@ -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") +) \ No newline at end of file diff --git a/hidrpc.go b/hidrpc.go index ebe03daa..c4c8c8ae 100644 --- a/hidrpc.go +++ b/hidrpc.go @@ -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") diff --git a/internal/session/permissions.go b/internal/session/permissions.go new file mode 100644 index 00000000..6fd10e3b --- /dev/null +++ b/internal/session/permissions.go @@ -0,0 +1,306 @@ +package session + +import "fmt" + +// Permission represents a specific action that can be performed +type Permission string + +const ( + // Video/Display permissions + PermissionVideoView Permission = "video.view" + + // Input permissions + PermissionKeyboardInput Permission = "keyboard.input" + PermissionMouseInput Permission = "mouse.input" + PermissionPaste Permission = "clipboard.paste" + + // Session management permissions + PermissionSessionTransfer Permission = "session.transfer" + PermissionSessionApprove Permission = "session.approve" + PermissionSessionKick Permission = "session.kick" + PermissionSessionRequestPrimary Permission = "session.request_primary" + PermissionSessionReleasePrimary Permission = "session.release_primary" + PermissionSessionManage Permission = "session.manage" + + // Power/USB control permissions + PermissionPowerControl Permission = "power.control" + PermissionUSBControl Permission = "usb.control" + + // Mount/Media permissions + PermissionMountMedia Permission = "mount.media" + PermissionUnmountMedia Permission = "mount.unmedia" + PermissionMountList Permission = "mount.list" + + // Extension permissions + PermissionExtensionManage Permission = "extension.manage" + + // Terminal/Serial permissions + PermissionTerminalAccess Permission = "terminal.access" + PermissionSerialAccess Permission = "serial.access" + PermissionExtensionATX Permission = "extension.atx" + PermissionExtensionDC Permission = "extension.dc" + PermissionExtensionSerial Permission = "extension.serial" + PermissionExtensionWOL Permission = "extension.wol" + + // Settings permissions + PermissionSettingsRead Permission = "settings.read" + PermissionSettingsWrite Permission = "settings.write" + PermissionSettingsAccess Permission = "settings.access" // Access control settings + + // System permissions + PermissionSystemReboot Permission = "system.reboot" + PermissionSystemUpdate Permission = "system.update" + PermissionSystemNetwork Permission = "system.network" +) + +// PermissionSet represents a set of permissions +type PermissionSet map[Permission]bool + +// RolePermissions defines permissions for each session mode +var RolePermissions = map[SessionMode]PermissionSet{ + SessionModePrimary: { + // Primary has all permissions + PermissionVideoView: true, + PermissionKeyboardInput: true, + PermissionMouseInput: true, + PermissionPaste: true, + PermissionSessionTransfer: true, + PermissionSessionApprove: true, + PermissionSessionKick: true, + PermissionSessionReleasePrimary: true, + PermissionMountMedia: true, + PermissionUnmountMedia: true, + PermissionMountList: true, + PermissionExtensionManage: true, + PermissionExtensionATX: true, + PermissionExtensionDC: true, + PermissionExtensionSerial: true, + PermissionExtensionWOL: true, + PermissionSettingsRead: true, + PermissionSettingsWrite: true, + PermissionSettingsAccess: true, // Only primary can access settings UI + PermissionSystemReboot: true, + PermissionSystemUpdate: true, + PermissionSystemNetwork: true, + PermissionTerminalAccess: true, + PermissionSerialAccess: true, + PermissionPowerControl: true, + PermissionUSBControl: true, + PermissionSessionManage: true, + PermissionSessionRequestPrimary: false, // Primary doesn't need to request + }, + SessionModeObserver: { + // Observers can only view + PermissionVideoView: true, + PermissionSessionRequestPrimary: true, + PermissionMountList: true, // Can see what's mounted but not mount/unmount + }, + SessionModeQueued: { + // Queued sessions can view and request primary + PermissionVideoView: true, + PermissionSessionRequestPrimary: true, + }, + SessionModePending: { + // Pending sessions have minimal permissions + PermissionVideoView: true, + }, +} + +// CheckPermission checks if a session mode has a specific permission +func CheckPermission(mode SessionMode, perm Permission) bool { + permissions, exists := RolePermissions[mode] + if !exists { + return false + } + return permissions[perm] +} + +// GetPermissionsForMode returns all permissions for a session mode +func GetPermissionsForMode(mode SessionMode) PermissionSet { + permissions, exists := RolePermissions[mode] + if !exists { + return PermissionSet{} + } + + // Return a copy to prevent modification + result := make(PermissionSet) + for k, v := range permissions { + result[k] = v + } + return result +} + +// RequirePermissionForMode is a middleware-like function for RPC handlers +func RequirePermissionForMode(mode SessionMode, perm Permission) error { + if !CheckPermission(mode, perm) { + return fmt.Errorf("permission denied: %s", perm) + } + return nil +} + +// GetPermissionsResponse is the response structure for getPermissions RPC +type GetPermissionsResponse struct { + Mode string `json:"mode"` + Permissions map[string]bool `json:"permissions"` +} + +// MethodPermissions maps RPC methods to required permissions +var MethodPermissions = map[string]Permission{ + // Power/hardware control + "setATXPowerAction": PermissionPowerControl, + "setDCPowerState": PermissionPowerControl, + "setDCRestoreState": PermissionPowerControl, + + // USB device control + "setUsbDeviceState": PermissionUSBControl, + "setUsbDevices": PermissionUSBControl, + + // Mount operations + "mountUsb": PermissionMountMedia, + "unmountUsb": PermissionMountMedia, + "mountBuiltInImage": PermissionMountMedia, + "rpcMountBuiltInImage": PermissionMountMedia, + "unmountImage": PermissionMountMedia, + "mountWithHTTP": PermissionMountMedia, + "mountWithStorage": PermissionMountMedia, + "checkMountUrl": PermissionMountMedia, + "startStorageFileUpload": PermissionMountMedia, + "deleteStorageFile": PermissionMountMedia, + + // Settings operations + "setDevModeState": PermissionSettingsWrite, + "setDevChannelState": PermissionSettingsWrite, + "setAutoUpdateState": PermissionSettingsWrite, + "tryUpdate": PermissionSettingsWrite, + "reboot": PermissionSettingsWrite, + "resetConfig": PermissionSettingsWrite, + "setNetworkSettings": PermissionSettingsWrite, + "setLocalLoopbackOnly": PermissionSettingsWrite, + "renewDHCPLease": PermissionSettingsWrite, + "setSSHKeyState": PermissionSettingsWrite, + "setTLSState": PermissionSettingsWrite, + "setVideoBandwidth": PermissionSettingsWrite, + "setVideoFramerate": PermissionSettingsWrite, + "setVideoResolution": PermissionSettingsWrite, + "setVideoEncoderQuality": PermissionSettingsWrite, + "setVideoSignal": PermissionSettingsWrite, + "setSerialBitrate": PermissionSettingsWrite, + "setSerialSettings": PermissionSettingsWrite, + "setSessionSettings": PermissionSessionManage, + "updateSessionSettings": PermissionSessionManage, + + // Display settings + "setEDID": PermissionSettingsWrite, + "setStreamQualityFactor": PermissionSettingsWrite, + "setDisplayRotation": PermissionSettingsWrite, + "setBacklightSettings": PermissionSettingsWrite, + + // USB/HID settings + "setUsbEmulationState": PermissionSettingsWrite, + "setUsbConfig": PermissionSettingsWrite, + "setKeyboardLayout": PermissionSettingsWrite, + "setJigglerState": PermissionSettingsWrite, + "setJigglerConfig": PermissionSettingsWrite, + "setMassStorageMode": PermissionSettingsWrite, + "setKeyboardMacros": PermissionSettingsWrite, + "setWakeOnLanDevices": PermissionSettingsWrite, + + // Cloud settings + "setCloudUrl": PermissionSettingsWrite, + "deregisterDevice": PermissionSettingsWrite, + + // Active extension control + "setActiveExtension": PermissionExtensionManage, + + // Input operations (already handled in other places but for consistency) + "keyboardReport": PermissionKeyboardInput, + "keypressReport": PermissionKeyboardInput, + "absMouseReport": PermissionMouseInput, + "relMouseReport": PermissionMouseInput, + "wheelReport": PermissionMouseInput, + "executeKeyboardMacro": PermissionPaste, + "cancelKeyboardMacro": PermissionPaste, + + // Session operations + "approveNewSession": PermissionSessionApprove, + "denyNewSession": PermissionSessionApprove, + "transferSession": PermissionSessionTransfer, + "transferPrimary": PermissionSessionTransfer, + "requestPrimary": PermissionSessionRequestPrimary, + "releasePrimary": PermissionSessionReleasePrimary, + + // Extension operations + "activateExtension": PermissionExtensionManage, + "deactivateExtension": PermissionExtensionManage, + "sendWOLMagicPacket": PermissionExtensionWOL, + + // Read operations - require appropriate read permissions + "getSessionSettings": PermissionSettingsRead, + "getSessionConfig": PermissionSettingsRead, + "getSessionData": PermissionVideoView, + "getNetworkSettings": PermissionSettingsRead, + "getSerialSettings": PermissionSettingsRead, + "getBacklightSettings": PermissionSettingsRead, + "getDisplayRotation": PermissionSettingsRead, + "getEDID": PermissionSettingsRead, + "get_edid": PermissionSettingsRead, + "getKeyboardLayout": PermissionSettingsRead, + "getJigglerConfig": PermissionSettingsRead, + "getJigglerState": PermissionSettingsRead, + "getStreamQualityFactor": PermissionSettingsRead, + "getVideoSettings": PermissionSettingsRead, + "getVideoBandwidth": PermissionSettingsRead, + "getVideoFramerate": PermissionSettingsRead, + "getVideoResolution": PermissionSettingsRead, + "getVideoEncoderQuality": PermissionSettingsRead, + "getVideoSignal": PermissionSettingsRead, + "getSerialBitrate": PermissionSettingsRead, + "getDevModeState": PermissionSettingsRead, + "getDevChannelState": PermissionSettingsRead, + "getAutoUpdateState": PermissionSettingsRead, + "getLocalLoopbackOnly": PermissionSettingsRead, + "getSSHKeyState": PermissionSettingsRead, + "getTLSState": PermissionSettingsRead, + "getCloudUrl": PermissionSettingsRead, + "getCloudState": PermissionSettingsRead, + "getNetworkState": PermissionSettingsRead, + + // Mount/media read operations + "getMassStorageMode": PermissionMountList, + "getUsbState": PermissionMountList, + "getUSBState": PermissionMountList, + "listStorageFiles": PermissionMountList, + "getStorageSpace": PermissionMountList, + + // Extension read operations + "getActiveExtension": PermissionSettingsRead, + + // Power state reads + "getATXState": PermissionSettingsRead, + "getDCPowerState": PermissionSettingsRead, + "getDCRestoreState": PermissionSettingsRead, + + // Device info reads (these should be accessible to all) + "getDeviceID": PermissionVideoView, + "getLocalVersion": PermissionVideoView, + "getVideoState": PermissionVideoView, + "getKeyboardLedState": PermissionVideoView, + "getKeyDownState": PermissionVideoView, + "ping": PermissionVideoView, + "getTimezones": PermissionVideoView, + "getSessions": PermissionVideoView, + "getUpdateStatus": PermissionSettingsRead, + "isUpdatePending": PermissionSettingsRead, + "getUsbEmulationState": PermissionSettingsRead, + "getUsbConfig": PermissionSettingsRead, + "getUsbDevices": PermissionSettingsRead, + "getKeyboardMacros": PermissionSettingsRead, + "getWakeOnLanDevices": PermissionSettingsRead, + "getVirtualMediaState": PermissionMountList, +} + +// GetMethodPermission returns the required permission for an RPC method +func GetMethodPermission(method string) (Permission, bool) { + perm, exists := MethodPermissions[method] + return perm, exists +} \ No newline at end of file diff --git a/internal/session/types.go b/internal/session/types.go new file mode 100644 index 00000000..50348d0e --- /dev/null +++ b/internal/session/types.go @@ -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" +) \ No newline at end of file diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 74cf76f9..d0b6eaa2 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -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 } diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index 374844f1..1f366d19 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -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() diff --git a/jiggler.go b/jiggler.go index b2463e0a..3323d0bb 100644 --- a/jiggler.go +++ b/jiggler.go @@ -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) } diff --git a/jsonrpc.go b/jsonrpc.go index 0ff44a78..f7e4b0a4 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "reflect" + "regexp" "strconv" "sync" "time" @@ -23,6 +24,14 @@ import ( "github.com/jetkvm/kvm/internal/utils" ) +// nicknameRegex defines the valid pattern for nicknames (matching frontend validation) +var nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.@]+$`) + +// isValidNickname checks if a nickname contains only valid characters +func isValidNickname(nickname string) bool { + return nicknameRegex.MatchString(nickname) +} + type JSONRPCRequest struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` @@ -47,6 +56,7 @@ type DisplayRotationSettings struct { Rotation string `json:"rotation"` } + type BacklightSettings struct { MaxBrightness int `json:"max_brightness"` DimAfter int `json:"dim_after"` @@ -54,11 +64,16 @@ type BacklightSettings struct { } func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { + if session == nil || session.RPCChannel == nil { + return + } + responseBytes, err := json.Marshal(response) if err != nil { jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response") return } + err = session.RPCChannel.SendText(string(responseBytes)) if err != nil { jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response") @@ -96,7 +111,30 @@ func writeJSONRPCEvent(event string, params any, session *Session) { } } +func broadcastJSONRPCEvent(event string, params any) { + sessionManager.ForEachSession(func(s *Session) { + writeJSONRPCEvent(event, params, s) + }) +} + func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { + // Rate limit check (DoS protection) + if !session.CheckRPCRateLimit() { + jsonRpcLogger.Warn(). + Str("sessionId", session.ID). + Msg("RPC rate limit exceeded") + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32000, + "message": "Rate limit exceeded", + }, + ID: 0, + } + writeJSONRPCResponse(errorResponse, session) + return + } + var request JSONRPCRequest err := json.Unmarshal(message.Data, &request) if err != nil { @@ -124,21 +162,206 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { scopedLogger.Trace().Msg("Received RPC request") - handler, ok := rpcHandlers[request.Method] - if !ok { - errorResponse := JSONRPCResponse{ - JSONRPC: "2.0", - Error: map[string]any{ - "code": -32601, - "message": "Method not found", - }, - ID: request.ID, + // Handle session-specific RPC methods first + var result any + var handlerErr error + + switch request.Method { + case "approvePrimaryRequest": + if err := RequirePermission(session, PermissionSessionTransfer); err != nil { + handlerErr = err + } else if requesterID, ok := request.Params["requesterID"].(string); ok { + handlerErr = sessionManager.ApprovePrimaryRequest(session.ID, requesterID) + if handlerErr == nil { + result = map[string]interface{}{"status": "approved"} + } + } else { + handlerErr = errors.New("invalid requesterID parameter") } - writeJSONRPCResponse(errorResponse, session) - return + case "denyPrimaryRequest": + if err := RequirePermission(session, PermissionSessionTransfer); err != nil { + handlerErr = err + } else if requesterID, ok := request.Params["requesterID"].(string); ok { + handlerErr = sessionManager.DenyPrimaryRequest(session.ID, requesterID) + if handlerErr == nil { + result = map[string]interface{}{"status": "denied"} + } + } else { + handlerErr = errors.New("invalid requesterID parameter") + } + case "approveNewSession": + if err := RequirePermission(session, PermissionSessionApprove); err != nil { + handlerErr = err + } else if sessionID, ok := request.Params["sessionId"].(string); ok { + if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { + targetSession.Mode = SessionModeObserver + sessionManager.broadcastSessionListUpdate() + result = map[string]interface{}{"status": "approved"} + } else { + handlerErr = errors.New("session not found or not pending") + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + case "denyNewSession": + if err := RequirePermission(session, PermissionSessionApprove); err != nil { + handlerErr = err + } else if sessionID, ok := request.Params["sessionId"].(string); ok { + if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { + writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{ + "message": "Access denied by primary session", + }, targetSession) + sessionManager.RemoveSession(sessionID) + result = map[string]interface{}{"status": "denied"} + } else { + handlerErr = errors.New("session not found or not pending") + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + case "updateSessionNickname": + sessionID, _ := request.Params["sessionId"].(string) + nickname, _ := request.Params["nickname"].(string) + // Validate nickname to match frontend validation + if len(nickname) < 2 { + handlerErr = errors.New("nickname must be at least 2 characters") + } else if len(nickname) > 30 { + handlerErr = errors.New("nickname must be 30 characters or less") + } else if !isValidNickname(nickname) { + handlerErr = errors.New("nickname can only contain letters, numbers, spaces, and - _ . @") + } else if targetSession := sessionManager.GetSession(sessionID); targetSession != nil { + // Users can update their own nickname, or admins can update any + if targetSession.ID == session.ID || session.HasPermission(PermissionSessionManage) { + targetSession.Nickname = nickname + + // If session is pending and approval is required, send the approval request now that we have a nickname + if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval { + if primary := sessionManager.GetPrimarySession(); primary != nil { + go func() { + writeJSONRPCEvent("newSessionPending", map[string]interface{}{ + "sessionId": targetSession.ID, + "source": targetSession.Source, + "identity": targetSession.Identity, + "nickname": targetSession.Nickname, + }, primary) + }() + } + } + + sessionManager.broadcastSessionListUpdate() + result = map[string]interface{}{"status": "updated"} + } else { + handlerErr = errors.New("permission denied: can only update own nickname") + } + } else { + handlerErr = errors.New("session not found") + } + case "getSessions": + sessions := sessionManager.GetAllSessions() + result = sessions + case "getPermissions": + permissions := session.GetPermissions() + permMap := make(map[string]bool) + for perm, allowed := range permissions { + permMap[string(perm)] = allowed + } + result = GetPermissionsResponse{ + Mode: string(session.Mode), + Permissions: permMap, + } + case "getSessionSettings": + if err := RequirePermission(session, PermissionSettingsRead); err != nil { + handlerErr = err + } else { + result = currentSessionSettings + } + case "setSessionSettings": + if err := RequirePermission(session, PermissionSessionManage); err != nil { + handlerErr = err + } else { + if settings, ok := request.Params["settings"].(map[string]interface{}); ok { + if requireApproval, ok := settings["requireApproval"].(bool); ok { + currentSessionSettings.RequireApproval = requireApproval + } + if requireNickname, ok := settings["requireNickname"].(bool); ok { + currentSessionSettings.RequireNickname = requireNickname + } + if reconnectGrace, ok := settings["reconnectGrace"].(float64); ok { + currentSessionSettings.ReconnectGrace = int(reconnectGrace) + } + if primaryTimeout, ok := settings["primaryTimeout"].(float64); ok { + currentSessionSettings.PrimaryTimeout = int(primaryTimeout) + } + if privateKeystrokes, ok := settings["privateKeystrokes"].(bool); ok { + currentSessionSettings.PrivateKeystrokes = privateKeystrokes + } + + // Trigger nickname auto-generation for sessions when RequireNickname changes + if sessionManager != nil { + sessionManager.updateAllSessionNicknames() + } + + // Save to persistent config + if err := SaveConfig(); err != nil { + handlerErr = errors.New("failed to save session settings") + } + result = currentSessionSettings + } else { + handlerErr = errors.New("invalid settings parameter") + } + } + case "generateNickname": + // Generate a nickname based on user agent (no permissions required) + userAgent := "" + if request.Params != nil { + if ua, ok := request.Params["userAgent"].(string); ok { + userAgent = ua + } + } + + // Use browser as fallback if no user agent provided + if userAgent == "" { + userAgent = "Mozilla/5.0 (Unknown) Browser" + } + + result = map[string]string{ + "nickname": generateNicknameFromUserAgent(userAgent), + } + default: + // Check method permissions using centralized permission system + if requiredPerm, exists := GetMethodPermission(request.Method); exists { + if !session.HasPermission(requiredPerm) { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32603, + "message": fmt.Sprintf("Permission denied: %s required", requiredPerm), + }, + ID: request.ID, + } + writeJSONRPCResponse(errorResponse, session) + return + } + } + + // Fall back to regular handlers + handler, ok := rpcHandlers[request.Method] + if !ok { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32601, + "message": "Method not found", + }, + ID: request.ID, + } + writeJSONRPCResponse(errorResponse, session) + return + } + result, handlerErr = callRPCHandler(scopedLogger, handler, request.Params) } - result, err := callRPCHandler(scopedLogger, handler, request.Params) + err = handlerErr if err != nil { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") errorResponse := JSONRPCResponse{ @@ -154,7 +377,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { return } - scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned") + scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully") response := JSONRPCResponse{ JSONRPC: "2.0", @@ -1084,6 +1307,93 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +func rpcGetSessions() ([]SessionData, error) { + return sessionManager.GetAllSessions(), nil +} + +func rpcGetSessionData(sessionId string) (SessionData, error) { + session := sessionManager.GetSession(sessionId) + if session == nil { + return SessionData{}, ErrSessionNotFound + } + return SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }, nil +} + +func rpcRequestPrimary(sessionId string) map[string]interface{} { + err := sessionManager.RequestPrimary(sessionId) + if err != nil { + return map[string]interface{}{ + "status": "error", + "message": err.Error(), + } + } + + // Check if the session was immediately promoted or queued + session := sessionManager.GetSession(sessionId) + if session == nil { + return map[string]interface{}{ + "status": "error", + "message": "session not found", + } + } + + return map[string]interface{}{ + "status": "success", + "mode": string(session.Mode), + } +} + +func rpcReleasePrimary(sessionId string) error { + return sessionManager.ReleasePrimary(sessionId) +} + +func rpcTransferPrimary(fromId string, toId string) error { + return sessionManager.TransferPrimary(fromId, toId) +} + + +func rpcGetSessionConfig() (map[string]interface{}, error) { + maxSessions := 10 + primaryTimeout := 300 + + if config != nil && config.MultiSession != nil { + if config.MultiSession.MaxSessions > 0 { + maxSessions = config.MultiSession.MaxSessions + } + if config.MultiSession.PrimaryTimeout > 0 { + primaryTimeout = config.MultiSession.PrimaryTimeout + } + } + + return map[string]interface{}{ + "enabled": true, + "maxSessions": maxSessions, + "primaryTimeout": primaryTimeout, + "allowCloudOverride": true, + }, nil +} + +func (s *Session) rpcApprovePrimaryRequest(requesterID string) error { + if s == nil || s.ID == "" { + return errors.New("invalid session") + } + return sessionManager.ApprovePrimaryRequest(s.ID, requesterID) +} + +func (s *Session) rpcDenyPrimaryRequest(requesterID string) error { + if s == nil || s.ID == "" { + return errors.New("invalid session") + } + return sessionManager.DenyPrimaryRequest(s.ID, requesterID) +} + var ( keyboardMacroCancel context.CancelFunc keyboardMacroLock sync.Mutex @@ -1119,8 +1429,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { IsPaste: true, } - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) + // Report to primary session if exists + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { + primarySession.reportHidRPCKeyboardMacroState(s) } err := rpcDoExecuteKeyboardMacro(ctx, macro) @@ -1128,8 +1439,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { setKeyboardMacroCancel(nil) s.State = false - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { + primarySession.reportHidRPCKeyboardMacroState(s) } return err @@ -1267,4 +1578,10 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getSessions": {Func: rpcGetSessions}, + "getSessionData": {Func: rpcGetSessionData, Params: []string{"sessionId"}}, + "getSessionConfig": {Func: rpcGetSessionConfig}, + "requestPrimary": {Func: rpcRequestPrimary, Params: []string{"sessionId"}}, + "releasePrimary": {Func: rpcReleasePrimary, Params: []string{"sessionId"}}, + "transferPrimary": {Func: rpcTransferPrimary, Params: []string{"fromId", "toId"}}, } diff --git a/main.go b/main.go index e9931d46..dec37695 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,18 @@ var appCtx context.Context func Main() { LoadConfig() + // Initialize currentSessionSettings to use config's persistent SessionSettings + if config.SessionSettings == nil { + config.SessionSettings = &SessionSettings{ + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + } + SaveConfig() + } + currentSessionSettings = config.SessionSettings + var cancel context.CancelFunc appCtx, cancel = context.WithCancel(context.Background()) defer cancel() @@ -91,7 +103,8 @@ func Main() { continue } - if currentSession != nil { + // Skip update if there's an active primary session + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { logger.Debug().Msg("skipping update since a session is active") time.Sleep(1 * time.Minute) continue diff --git a/native.go b/native.go index e8eea745..e74335e9 100644 --- a/native.go +++ b/native.go @@ -48,12 +48,21 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { } }, OnVideoFrameReceived: func(frame []byte, duration time.Duration) { - if currentSession != nil { - err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}) - if err != nil { - nativeLogger.Warn().Err(err).Msg("error writing sample") + sessionManager.ForEachSession(func(s *Session) { + if !sessionManager.CanReceiveVideo(s, currentSessionSettings) { + return } - } + + if s.VideoTrack != nil { + err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}) + if err != nil { + nativeLogger.Warn(). + Str("sessionID", s.ID). + Err(err). + Msg("error writing sample to session") + } + } + }) }, }) nativeInstance.Start() diff --git a/network.go b/network.go index b808d6fe..ff5d1de1 100644 --- a/network.go +++ b/network.go @@ -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 diff --git a/ota.go b/ota.go index bf0828dc..e1520cce 100644 --- a/ota.go +++ b/ota.go @@ -302,11 +302,7 @@ var otaState = OTAState{} func triggerOTAStateUpdate() { go func() { - if currentSession == nil { - logger.Info().Msg("No active RPC session, skipping update state update") - return - } - writeJSONRPCEvent("otaState", otaState, currentSession) + broadcastJSONRPCEvent("otaState", otaState) }() } diff --git a/serial.go b/serial.go index 5439d135..c0702eae 100644 --- a/serial.go +++ b/serial.go @@ -57,12 +57,10 @@ func runATXControl() { newBtnRSTState := line[2] == '1' newBtnPWRState := line[3] == '1' - if currentSession != nil { - writeJSONRPCEvent("atxState", ATXState{ - Power: newLedPWRState, - HDD: newLedHDDState, - }, currentSession) - } + broadcastJSONRPCEvent("atxState", ATXState{ + Power: newLedPWRState, + HDD: newLedHDDState, + }) if newLedHDDState != ledHDDState || newLedPWRState != ledPWRState || @@ -210,9 +208,7 @@ func runDCControl() { // Update Prometheus metrics updateDCMetrics(dcState) - if currentSession != nil { - writeJSONRPCEvent("dcState", dcState, currentSession) - } + broadcastJSONRPCEvent("dcState", dcState) } } @@ -284,9 +280,16 @@ func reopenSerialPort() error { return nil } -func handleSerialChannel(d *webrtc.DataChannel) { +func handleSerialChannel(d *webrtc.DataChannel, session *Session) { scopedLogger := serialLogger.With(). - Uint16("data_channel_id", *d.ID()).Logger() + Uint16("data_channel_id", *d.ID()). + Str("session_id", session.ID).Logger() + + // Check serial access permission + if !session.HasPermission(PermissionSerialAccess) { + handlePermissionDeniedChannel(d, "Serial port access denied: Permission required") + return + } d.OnOpen(func() { go func() { diff --git a/session_manager.go b/session_manager.go new file mode 100644 index 00000000..87ddf7d7 --- /dev/null +++ b/session_manager.go @@ -0,0 +1,1633 @@ +package kvm + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +// SessionMode and constants are now imported from internal/session via session_permissions.go + +var ( + ErrMaxSessionsReached = errors.New("maximum number of sessions reached") +) + +type SessionData struct { + ID string `json:"id"` + Mode SessionMode `json:"mode"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastActive time.Time `json:"last_active"` +} + +// Event types for JSON-RPC notifications +type ( + SessionsUpdateEvent struct { + Sessions []SessionData `json:"sessions"` + YourMode SessionMode `json:"yourMode"` + } + + NewSessionPendingEvent struct { + SessionID string `json:"sessionId"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + } + + PrimaryRequestEvent struct { + RequestID string `json:"requestId"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + } +) + +// TransferBlacklistEntry prevents recently demoted sessions from immediately becoming primary again +type TransferBlacklistEntry struct { + SessionID string + ExpiresAt time.Time +} + +// Broadcast throttling to prevent DoS +var ( + lastBroadcast time.Time + broadcastMutex sync.Mutex + broadcastDelay = 100 * time.Millisecond // Min time between broadcasts + + // Pre-allocated event maps to reduce allocations + modePrimaryEvent = map[string]string{"mode": "primary"} + modeObserverEvent = map[string]string{"mode": "observer"} +) + +type SessionManager struct { + mu sync.RWMutex // 24 bytes - place first for better alignment + primaryTimeout time.Duration // 8 bytes + logger *zerolog.Logger // 8 bytes + sessions map[string]*Session // 8 bytes + reconnectGrace map[string]time.Time // 8 bytes + reconnectInfo map[string]*SessionData // 8 bytes + transferBlacklist []TransferBlacklistEntry // Prevent demoted sessions from immediate re-promotion + queueOrder []string // 24 bytes (slice header) + primarySessionID string // 16 bytes + lastPrimaryID string // 16 bytes + maxSessions int // 8 bytes + cleanupCancel context.CancelFunc // For stopping cleanup goroutine + + // Emergency promotion tracking for safety + lastEmergencyPromotion time.Time + consecutiveEmergencyPromotions int +} + +// NewSessionManager creates a new session manager +func NewSessionManager(logger *zerolog.Logger) *SessionManager { + // Use configuration values if available + maxSessions := 10 + primaryTimeout := 5 * time.Minute + + if config != nil && config.MultiSession != nil { + if config.MultiSession.MaxSessions > 0 { + maxSessions = config.MultiSession.MaxSessions + } + if config.MultiSession.PrimaryTimeout > 0 { + primaryTimeout = time.Duration(config.MultiSession.PrimaryTimeout) * time.Second + } + } + + // Override with session settings if available + if currentSessionSettings != nil && currentSessionSettings.PrimaryTimeout > 0 { + primaryTimeout = time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second + } + + sm := &SessionManager{ + sessions: make(map[string]*Session), + reconnectGrace: make(map[string]time.Time), + reconnectInfo: make(map[string]*SessionData), + transferBlacklist: make([]TransferBlacklistEntry, 0), + queueOrder: make([]string, 0), + logger: logger, + maxSessions: maxSessions, + primaryTimeout: primaryTimeout, + } + + // Start background cleanup of inactive sessions + ctx, cancel := context.WithCancel(context.Background()) + sm.cleanupCancel = cancel + go sm.cleanupInactiveSessions(ctx) + + return sm +} + +func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSettings) error { + // Basic input validation + if session == nil { + return errors.New("session cannot be nil") + } + // Validate nickname if provided (matching frontend validation) + if session.Nickname != "" { + if len(session.Nickname) < 2 { + return errors.New("nickname must be at least 2 characters") + } + if len(session.Nickname) > 30 { + return errors.New("nickname must be 30 characters or less") + } + // Note: Pattern validation is done in RPC layer, not here for performance + } + if len(session.Identity) > 256 { + return errors.New("identity too long") + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + // Check if this session ID is within grace period for reconnection + wasWithinGracePeriod := false + wasPreviouslyPrimary := false + if graceTime, exists := sm.reconnectGrace[session.ID]; exists { + if time.Now().Before(graceTime) { + wasWithinGracePeriod = true + // Check if this was specifically the primary + wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) + } + // Clean up grace period entry + delete(sm.reconnectGrace, session.ID) + } + + // Check if a session with this ID already exists (reconnection) + if existing, exists := sm.sessions[session.ID]; exists { + // SECURITY: Verify identity matches to prevent session hijacking + if existing.Identity != session.Identity || existing.Source != session.Source { + return fmt.Errorf("session ID already in use by different user (identity mismatch)") + } + + // CRITICAL: Close old connection to prevent multiple active connections for same session ID + if existing.peerConnection != nil { + sm.logger.Info(). + Str("sessionID", session.ID). + Msg("Closing old peer connection for session reconnection") + existing.peerConnection.Close() + } + + // Update the existing session with new connection details + existing.peerConnection = session.peerConnection + existing.VideoTrack = session.VideoTrack + existing.ControlChannel = session.ControlChannel + existing.RPCChannel = session.RPCChannel + existing.HidChannel = session.HidChannel + existing.LastActive = time.Now() + existing.flushCandidates = session.flushCandidates + // Preserve existing mode and nickname + session.Mode = existing.Mode + session.Nickname = existing.Nickname + session.CreatedAt = existing.CreatedAt + + // Ensure session has auto-generated nickname if needed + sm.ensureNickname(session) + + sm.sessions[session.ID] = session + + // If this was the primary, try to restore primary status + if existing.Mode == SessionModePrimary { + // Check if this session is still the reserved primary AND not blacklisted + isBlacklisted := sm.isSessionBlacklisted(session.ID) + if sm.lastPrimaryID == session.ID && !isBlacklisted { + // This is the rightful primary reconnecting within grace period + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" // Clear since primary successfully reconnected + delete(sm.reconnectGrace, session.ID) // Clear grace period + sm.logger.Debug(). + Str("sessionID", session.ID). + Msg("Primary session successfully reconnected within grace period") + } else { + // This session was primary but grace period expired, another took over, or is blacklisted + session.Mode = SessionModeObserver + sm.logger.Debug(). + Str("sessionID", session.ID). + Str("currentPrimaryID", sm.primarySessionID). + Bool("isBlacklisted", isBlacklisted). + Msg("Former primary session reconnected but grace period expired, another took over, or session is blacklisted - demoting to observer") + } + } + + // NOTE: Skip validation during reconnection to preserve grace period + // validateSinglePrimary() would clear primary slot during reconnection window + + go sm.broadcastSessionListUpdate() + return nil + } + + if len(sm.sessions) >= sm.maxSessions { + return ErrMaxSessionsReached + } + + // Generate ID if not set + if session.ID == "" { + session.ID = uuid.New().String() + } + + // Clean up any grace period entries for this session since it's reconnecting + if wasWithinGracePeriod { + delete(sm.reconnectGrace, session.ID) + delete(sm.reconnectInfo, session.ID) + sm.logger.Info(). + Str("sessionID", session.ID). + Msg("Session reconnected within grace period - cleaned up grace period entries") + } + + // Set nickname from client settings if provided + if clientSettings != nil && clientSettings.Nickname != "" { + session.Nickname = clientSettings.Nickname + } + + // Use global settings for requirements (not client-provided) + globalSettings := currentSessionSettings + + // Set mode based on current state and global settings + // ATOMIC CHECK AND ASSIGN: Check if there's currently no primary session + // and assign primary status atomically to prevent race conditions + primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil + + // Check if this session was recently demoted via transfer + isBlacklisted := sm.isSessionBlacklisted(session.ID) + + sm.logger.Debug(). + Str("newSessionID", session.ID). + Str("nickname", session.Nickname). + Str("currentPrimarySessionID", sm.primarySessionID). + Bool("primaryExists", primaryExists). + Int("totalSessions", len(sm.sessions)). + Bool("wasWithinGracePeriod", wasWithinGracePeriod). + Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("isBlacklisted", isBlacklisted). + Msg("AddSession state analysis") + + // Become primary only if: + // 1. Was previously primary (within grace) AND no current primary, OR + // 2. There's no primary at all AND not recently transferred away + // Never allow primary promotion if already restored within grace period + shouldBecomePrimary := !wasWithinGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted)) + + if wasWithinGracePeriod { + sm.logger.Debug(). + Str("sessionID", session.ID). + Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("primaryExists", primaryExists). + Str("currentPrimarySessionID", sm.primarySessionID). + Msg("Session within grace period - skipping primary promotion logic") + } + + if shouldBecomePrimary { + // Double-check primary doesn't exist (race condition prevention) + if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil { + // Since we now generate nicknames automatically when required, + // we can always promote to primary when no primary exists + session.Mode = SessionModePrimary + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" // Clear since we have a new primary + + // Clear all existing grace periods when a new primary is established + // This prevents multiple sessions from fighting for primary status via grace period + if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { + sm.logger.Debug(). + Int("clearedGracePeriods", len(sm.reconnectGrace)). + Int("clearedReconnectInfo", len(sm.reconnectInfo)). + Str("newPrimarySessionID", session.ID). + Msg("Clearing all existing grace periods for new primary session in AddSession") + + // Clear all existing grace periods and reconnect info + for oldSessionID := range sm.reconnectGrace { + delete(sm.reconnectGrace, oldSessionID) + } + for oldSessionID := range sm.reconnectInfo { + delete(sm.reconnectInfo, oldSessionID) + } + } + + // Reset HID availability to force re-handshake for input functionality + session.hidRPCAvailable = false + } else { + // Someone else became primary in the meantime, become observer + session.Mode = SessionModeObserver + } + } else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { + // New session requires approval from primary (but only if there IS a primary to approve) + // Skip approval for sessions reconnecting within grace period + session.Mode = SessionModePending + // Notify primary about the pending session, but only if nickname is not required OR already provided + if primary := sm.sessions[sm.primarySessionID]; primary != nil { + // Check if nickname is required and missing + requiresNickname := globalSettings.RequireNickname + hasNickname := session.Nickname != "" && len(session.Nickname) > 0 + + // Only send approval request if nickname is not required OR already provided + if !requiresNickname || hasNickname { + go func() { + writeJSONRPCEvent("newSessionPending", map[string]interface{}{ + "sessionId": session.ID, + "source": session.Source, + "identity": session.Identity, + "nickname": session.Nickname, + }, primary) + }() + } + // If nickname is required and missing, the approval request will be sent + // later when updateSessionNickname is called (see jsonrpc.go:232-242) + } + } else { + // No primary exists and approval is required, OR approval is not required + // In either case, this session becomes an observer + session.Mode = SessionModeObserver + } + + session.CreatedAt = time.Now() + session.LastActive = time.Now() + + // Add session to sessions map BEFORE primary checks + // This ensures that primary existence checks work correctly during restoration + sm.sessions[session.ID] = session + + // Ensure session has auto-generated nickname if needed + sm.ensureNickname(session) + + // Validate sessions but respect grace periods + sm.validateSinglePrimary() + + // Notify all sessions about the new connection + go sm.broadcastSessionListUpdate() + + return nil +} + +// RemoveSession removes a session from the manager +func (sm *SessionManager) RemoveSession(sessionID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return + } + + wasPrimary := session.Mode == SessionModePrimary + delete(sm.sessions, sessionID) + + // Remove from queue if present + sm.removeFromQueue(sessionID) + + // Add a grace period for reconnection for all sessions + // Use configured grace period or default to 10 seconds + gracePeriod := 10 + if currentSessionSettings != nil && currentSessionSettings.ReconnectGrace > 0 { + gracePeriod = currentSessionSettings.ReconnectGrace + } + + // Limit grace period entries to prevent memory exhaustion (DoS protection) + const maxGraceEntries = 10 // Reduced from 20 to limit memory usage + for len(sm.reconnectGrace) >= maxGraceEntries { + // Find and remove the oldest grace period entry + var oldestID string + var oldestTime time.Time + for id, graceTime := range sm.reconnectGrace { + if oldestTime.IsZero() || graceTime.Before(oldestTime) { + oldestID = id + oldestTime = graceTime + } + } + if oldestID != "" { + delete(sm.reconnectGrace, oldestID) + delete(sm.reconnectInfo, oldestID) + } else { + break // Safety check to prevent infinite loop + } + } + + sm.reconnectGrace[sessionID] = time.Now().Add(time.Duration(gracePeriod) * time.Second) + + // Store session info for potential reconnection + sm.reconnectInfo[sessionID] = &SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + } + + // If this was the primary session, clear primary slot and track for grace period + if wasPrimary { + sm.lastPrimaryID = sessionID // Remember this was the primary for grace period + sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted + sm.logger.Info(). + Str("sessionID", sessionID). + Dur("gracePeriod", time.Duration(gracePeriod)*time.Second). + Msg("Primary session removed, grace period active - auto-promotion will occur after grace expires") + + // NOTE: Do NOT call validateSinglePrimary() here - let grace period expire naturally + // The cleanupInactiveSessions() function will handle promotion after grace period expires + } + + // Notify remaining sessions + go sm.broadcastSessionListUpdate() +} + +// GetSession returns a session by ID +func (sm *SessionManager) GetSession(sessionID string) *Session { + sm.mu.RLock() + session := sm.sessions[sessionID] + sm.mu.RUnlock() + return session +} + +// IsValidReconnection checks if a session ID can be reused for reconnection +func (sm *SessionManager) IsValidReconnection(sessionID, source, identity string) bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Check if session is in reconnect grace period + if info, exists := sm.reconnectInfo[sessionID]; exists { + // Verify the source and identity match + return info.Source == source && info.Identity == identity + } + + return false +} + +// IsInGracePeriod checks if a session ID is within the reconnection grace period +func (sm *SessionManager) IsInGracePeriod(sessionID string) bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if graceTime, exists := sm.reconnectGrace[sessionID]; exists { + return time.Now().Before(graceTime) + } + return false +} + +// isSessionBlacklisted checks if a session was recently demoted via transfer and should not become primary +func (sm *SessionManager) isSessionBlacklisted(sessionID string) bool { + now := time.Now() + + // Clean expired entries while we're here + validEntries := make([]TransferBlacklistEntry, 0, len(sm.transferBlacklist)) + for _, entry := range sm.transferBlacklist { + if now.Before(entry.ExpiresAt) { + validEntries = append(validEntries, entry) + if entry.SessionID == sessionID { + return true // Found active blacklist entry + } + } + } + sm.transferBlacklist = validEntries // Update with only non-expired entries + + return false +} + +// GetPrimarySession returns the current primary session +func (sm *SessionManager) GetPrimarySession() *Session { + sm.mu.RLock() + if sm.primarySessionID == "" { + sm.mu.RUnlock() + return nil + } + session := sm.sessions[sm.primarySessionID] + sm.mu.RUnlock() + return session +} + +// SetPrimarySession sets a session as primary +func (sm *SessionManager) SetPrimarySession(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + session.Mode = SessionModePrimary + sm.primarySessionID = sessionID + sm.lastPrimaryID = "" + return nil +} + +// CanReceiveVideo checks if a session is allowed to receive video +// Sessions in pending state cannot receive video +// Sessions that require nickname but don't have one also cannot receive video (if enforced) +func (sm *SessionManager) CanReceiveVideo(session *Session, settings *SessionSettings) bool { + // Check if session has video view permission + if !session.HasPermission(PermissionVideoView) { + return false + } + + // If nickname is required and session doesn't have one, block video + if settings != nil && settings.RequireNickname && session.Nickname == "" { + return false + } + + return true +} + +// GetAllSessions returns information about all active sessions +func (sm *SessionManager) GetAllSessions() []SessionData { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Don't run validation on every getSessions call + // This was causing immediate demotion during transfers and page refreshes + // Validation should only run during state changes, not data queries + + infos := make([]SessionData, 0, len(sm.sessions)) + for _, session := range sm.sessions { + infos = append(infos, SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }) + } + return infos +} + +// RequestPrimary requests primary control for a session +func (sm *SessionManager) RequestPrimary(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + // If already primary, nothing to do + if session.Mode == SessionModePrimary { + return nil + } + + // Check if there's a primary in grace period before promoting + if sm.primarySessionID == "" { + // Don't promote immediately if there's a primary waiting in grace period + if sm.lastPrimaryID != "" { + // Check if grace period is still active + if graceTime, exists := sm.reconnectGrace[sm.lastPrimaryID]; exists { + if time.Now().Before(graceTime) { + // Primary is in grace period, queue this request instead + sm.queueOrder = append(sm.queueOrder, sessionID) + session.Mode = SessionModeQueued + sm.logger.Info(). + Str("sessionID", sessionID). + Str("gracePrimaryID", sm.lastPrimaryID). + Msg("Request queued - primary session in grace period") + go sm.broadcastSessionListUpdate() + return nil + } + } + } + + // No grace period conflict, promote immediately using centralized system + err := sm.transferPrimaryRole("", sessionID, "initial_promotion", "first session auto-promotion") + if err == nil { + // Send mode change event after promoting + writeJSONRPCEvent("modeChanged", modePrimaryEvent, session) + go sm.broadcastSessionListUpdate() + } + return err + } + + // Notify the primary session about the request + if primarySession, exists := sm.sessions[sm.primarySessionID]; exists { + event := PrimaryRequestEvent{ + RequestID: sessionID, + Identity: session.Identity, + Source: session.Source, + Nickname: session.Nickname, + } + writeJSONRPCEvent("primaryControlRequested", event, primarySession) + } + + // Add to queue if not already there + if session.Mode != SessionModeQueued { + session.Mode = SessionModeQueued + sm.queueOrder = append(sm.queueOrder, sessionID) + } + + // Broadcast update in goroutine to avoid deadlock + go sm.broadcastSessionListUpdate() + return nil +} + +// ReleasePrimary releases primary control from a session +func (sm *SessionManager) ReleasePrimary(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + if session.Mode != SessionModePrimary { + return nil + } + + // Check if there are other sessions that could take control + hasOtherEligibleSessions := false + for id, s := range sm.sessions { + if id != sessionID && (s.Mode == SessionModeObserver || s.Mode == SessionModeQueued) { + hasOtherEligibleSessions = true + break + } + } + + // Don't allow releasing primary if no one else can take control + if !hasOtherEligibleSessions { + return errors.New("cannot release primary control - no other sessions available") + } + + // Demote to observer + session.Mode = SessionModeObserver + sm.primarySessionID = "" + + // Clear any active input state + sm.clearInputState() + + // Find the next session to promote (excluding the current primary) + // For voluntary releases, ignore blacklisting since this is user-initiated + promotedSessionID := sm.findNextSessionToPromoteExcludingIgnoreBlacklist(sessionID) + + // If we found someone to promote, use centralized transfer + if promotedSessionID != "" { + err := sm.transferPrimaryRole(sessionID, promotedSessionID, "release_transfer", "primary release and auto-promotion") + if err != nil { + sm.logger.Error(). + Str("error", err.Error()). + Str("releasedBySessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Msg("Failed to transfer primary role after release") + return err + } + + sm.logger.Info(). + Str("releasedBySessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Msg("Primary control released and transferred to observer") + + // Send mode change event for promoted session + go func() { + if promotedSession := sessionManager.GetSession(promotedSessionID); promotedSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, promotedSession) + } + }() + } else { + sm.logger.Warn(). + Str("releasedBySessionID", sessionID). + Msg("Primary control released but no eligible sessions found for promotion") + } + + // Broadcast update in goroutine to avoid deadlock + go sm.broadcastSessionListUpdate() + return nil +} + +// TransferPrimary transfers primary control from one session to another +func (sm *SessionManager) TransferPrimary(fromID, toID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Use centralized transfer method + err := sm.transferPrimaryRole(fromID, toID, "direct_transfer", "manual transfer request") + if err != nil { + return err + } + + // Send events in goroutines to avoid holding lock + go func() { + if fromSession := sessionManager.GetSession(fromID); fromSession != nil { + writeJSONRPCEvent("modeChanged", modeObserverEvent, fromSession) + } + }() + + go func() { + if toSession := sessionManager.GetSession(toID); toSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, toSession) + } + sm.broadcastSessionListUpdate() + }() + + return nil +} + +// ApprovePrimaryRequest approves a pending primary control request +func (sm *SessionManager) ApprovePrimaryRequest(currentPrimaryID, requesterID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Log the approval request + sm.logger.Info(). + Str("currentPrimaryID", currentPrimaryID). + Str("requesterID", requesterID). + Str("actualPrimaryID", sm.primarySessionID). + Msg("ApprovePrimaryRequest called") + + // Verify current primary is correct + if sm.primarySessionID != currentPrimaryID { + sm.logger.Error(). + Str("currentPrimaryID", currentPrimaryID). + Str("actualPrimaryID", sm.primarySessionID). + Msg("Not the primary session") + return errors.New("not the primary session") + } + + // Remove requester from queue + sm.removeFromQueue(requesterID) + + // Use centralized transfer method + err := sm.transferPrimaryRole(currentPrimaryID, requesterID, "approval_transfer", "primary approval request") + if err != nil { + return err + } + + // Send events after releasing lock to avoid deadlock + go func() { + if demotedSession := sessionManager.GetSession(currentPrimaryID); demotedSession != nil { + writeJSONRPCEvent("modeChanged", modeObserverEvent, demotedSession) + } + }() + + go func() { + if promotedSession := sessionManager.GetSession(requesterID); promotedSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, promotedSession) + } + sm.broadcastSessionListUpdate() + }() + + return nil +} + +// DenyPrimaryRequest denies a pending primary control request +func (sm *SessionManager) DenyPrimaryRequest(currentPrimaryID, requesterID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Verify current primary is correct + if sm.primarySessionID != currentPrimaryID { + return errors.New("not the primary session") + } + + requester, exists := sm.sessions[requesterID] + if !exists { + return ErrSessionNotFound + } + + // Move requester back to observer + requester.Mode = SessionModeObserver + sm.removeFromQueue(requesterID) + + // Validate session consistency after mode change + sm.validateSinglePrimary() + + // Notify requester of denial in goroutine + go func() { + writeJSONRPCEvent("primaryControlDenied", map[string]interface{}{}, requester) + sm.broadcastSessionListUpdate() + }() + + return nil +} + +// ForEachSession executes a function for each active session +func (sm *SessionManager) ForEachSession(fn func(*Session)) { + sm.mu.RLock() + // Create a copy of sessions to avoid holding lock during callbacks + sessionsCopy := make([]*Session, 0, len(sm.sessions)) + for _, session := range sm.sessions { + sessionsCopy = append(sessionsCopy, session) + } + sm.mu.RUnlock() + + // Call function outside of lock to prevent deadlocks + for _, session := range sessionsCopy { + fn(session) + } +} + +// UpdateLastActive updates the last active time for a session +func (sm *SessionManager) UpdateLastActive(sessionID string) { + sm.mu.Lock() + if session, exists := sm.sessions[sessionID]; exists { + session.LastActive = time.Now() + } + sm.mu.Unlock() +} + +// Internal helper methods + +// validateSinglePrimary ensures there's only one primary session and fixes any inconsistencies +func (sm *SessionManager) validateSinglePrimary() { + primarySessions := make([]*Session, 0) + + // Find all sessions that think they're primary + for _, session := range sm.sessions { + if session.Mode == SessionModePrimary { + primarySessions = append(primarySessions, session) + } + } + + // If we have multiple primaries, this is a critical bug - fix it + if len(primarySessions) > 1 { + sm.logger.Error(). + Int("primaryCount", len(primarySessions)). + Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...") + + // Keep the first one as primary, demote the rest + for i, session := range primarySessions { + if i == 0 { + // Keep this as primary and update manager state + sm.primarySessionID = session.ID + sm.logger.Info(). + Str("keptPrimaryID", session.ID). + Msg("Kept session as primary") + } else { + // Demote all others + session.Mode = SessionModeObserver + sm.logger.Info(). + Str("demotedSessionID", session.ID). + Msg("Demoted duplicate primary session") + } + } + } + + // Ensure manager's primarySessionID matches reality + if len(primarySessions) == 1 && sm.primarySessionID != primarySessions[0].ID { + sm.logger.Warn(). + Str("managerPrimaryID", sm.primarySessionID). + Str("actualPrimaryID", primarySessions[0].ID). + Msg("Manager primary ID mismatch, fixing...") + sm.primarySessionID = primarySessions[0].ID + } + + // Don't clear primary slot if there's a grace period active + // This prevents instant promotion during primary session reconnection + if len(primarySessions) == 0 && sm.primarySessionID != "" { + // Check if the current primary is in grace period waiting to reconnect + if sm.lastPrimaryID == sm.primarySessionID { + if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists { + if time.Now().Before(graceTime) { + // Primary is in grace period, DON'T clear the slot yet + sm.logger.Info(). + Str("gracePrimaryID", sm.primarySessionID). + Msg("Primary slot preserved - session in grace period") + return // Exit validation, keep primary slot reserved + } + } + } + + // No grace period, safe to clear orphaned primary + sm.logger.Warn(). + Str("orphanedPrimaryID", sm.primarySessionID). + Msg("Cleared orphaned primary ID") + sm.primarySessionID = "" + } + + sm.logger.Debug(). + Int("primarySessionCount", len(primarySessions)). + Str("primarySessionID", sm.primarySessionID). + Int("totalSessions", len(sm.sessions)). + Msg("validateSinglePrimary state check") + + // Auto-promote if there are NO primary sessions at all + if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 { + // Find a session to promote to primary + nextSessionID := sm.findNextSessionToPromote() + if nextSessionID != "" { + sm.logger.Info(). + Str("promotedSessionID", nextSessionID). + Msg("Auto-promoting observer to primary - no primary sessions exist") + + // Use the centralized promotion logic + err := sm.transferPrimaryRole("", nextSessionID, "emergency_auto_promotion", "no primary sessions detected") + if err != nil { + sm.logger.Error(). + Err(err). + Str("sessionID", nextSessionID). + Msg("Failed to auto-promote session to primary") + } + } else { + sm.logger.Warn(). + Msg("No eligible session found for emergency auto-promotion") + } + } else { + sm.logger.Debug(). + Int("primarySessions", len(primarySessions)). + Str("primarySessionID", sm.primarySessionID). + Bool("hasSessions", len(sm.sessions) > 0). + Msg("Emergency auto-promotion conditions not met") + } +} + +// transferPrimaryRole is the centralized method for all primary role transfers +// It handles bidirectional blacklisting and logging consistently across all transfer types +func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transferType, context string) error { + // Validate sessions exist + toSession, toExists := sm.sessions[toSessionID] + if !toExists { + return ErrSessionNotFound + } + + var fromSession *Session + var fromExists bool + if fromSessionID != "" { + fromSession, fromExists = sm.sessions[fromSessionID] + if !fromExists { + return ErrSessionNotFound + } + } + + // Demote existing primary if specified + if fromExists && fromSession.Mode == SessionModePrimary { + fromSession.Mode = SessionModeObserver + fromSession.hidRPCAvailable = false + delete(sm.reconnectGrace, fromSessionID) + delete(sm.reconnectInfo, fromSessionID) + + sm.logger.Info(). + Str("demotedSessionID", fromSessionID). + Str("transferType", transferType). + Str("context", context). + Msg("Demoted existing primary session") + } + + // Promote target session + toSession.Mode = SessionModePrimary + toSession.hidRPCAvailable = false // Force re-handshake + sm.primarySessionID = toSessionID + sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh + + // Clear input state + sm.clearInputState() + + // Reset consecutive emergency promotion counter on successful manual transfer + if fromSessionID != "" && transferType != "emergency_promotion_deadlock_prevention" && transferType != "emergency_timeout_promotion" { + sm.consecutiveEmergencyPromotions = 0 + } + + // Apply bidirectional blacklisting - protect newly promoted session + now := time.Now() + blacklistDuration := 60 * time.Second + blacklistedCount := 0 + + // First, clear any existing blacklist entries for the newly promoted session + cleanedBlacklist := make([]TransferBlacklistEntry, 0) + for _, entry := range sm.transferBlacklist { + if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary + cleanedBlacklist = append(cleanedBlacklist, entry) + } + } + sm.transferBlacklist = cleanedBlacklist + + // Then blacklist all other sessions + for sessionID := range sm.sessions { + if sessionID != toSessionID { // Don't blacklist the newly promoted session + sm.transferBlacklist = append(sm.transferBlacklist, TransferBlacklistEntry{ + SessionID: sessionID, + ExpiresAt: now.Add(blacklistDuration), + }) + blacklistedCount++ + } + } + + // Clear all grace periods to prevent conflicts + if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { + for oldSessionID := range sm.reconnectGrace { + delete(sm.reconnectGrace, oldSessionID) + } + for oldSessionID := range sm.reconnectInfo { + delete(sm.reconnectInfo, oldSessionID) + } + } + + sm.logger.Info(). + Str("fromSessionID", fromSessionID). + Str("toSessionID", toSessionID). + Str("transferType", transferType). + Str("context", context). + Int("blacklistedSessions", blacklistedCount). + Dur("blacklistDuration", blacklistDuration). + Msg("Primary role transferred with bidirectional protection") + + // Validate session consistency after role transfer + sm.validateSinglePrimary() + + // Handle WebRTC connection state for promoted sessions + // When a session changes from observer to primary, the existing WebRTC connection + // was established for observer mode and needs to be re-negotiated for primary mode + if toExists && (transferType == "emergency_timeout_promotion" || transferType == "emergency_auto_promotion") { + go func() { + // Small delay to ensure session mode changes are committed + time.Sleep(100 * time.Millisecond) + + // Send connection reset signal to the promoted session + writeJSONRPCEvent("connectionModeChanged", map[string]interface{}{ + "sessionId": toSessionID, + "newMode": string(toSession.Mode), + "reason": "session_promotion", + "action": "reconnect_required", + "timestamp": time.Now().Unix(), + }, toSession) + + sm.logger.Info(). + Str("sessionId", toSessionID). + Str("newMode", string(toSession.Mode)). + Str("transferType", transferType). + Msg("Sent WebRTC reconnection signal to promoted session") + }() + } + + return nil +} + +// findNextSessionToPromote finds the next eligible session for promotion +// Replicates the logic from promoteNextSession but just returns the session ID +func (sm *SessionManager) findNextSessionToPromote() string { + return sm.findNextSessionToPromoteExcluding("", true) +} + +func (sm *SessionManager) findNextSessionToPromoteExcluding(excludeSessionID string, checkBlacklist bool) string { + // First, check if there are queued sessions (excluding the specified session) + if len(sm.queueOrder) > 0 { + nextID := sm.queueOrder[0] + if nextID != excludeSessionID { + if _, exists := sm.sessions[nextID]; exists { + if !checkBlacklist || !sm.isSessionBlacklisted(nextID) { + return nextID + } + } + } + } + + // Otherwise, find any observer session (excluding the specified session) + for id, session := range sm.sessions { + if id != excludeSessionID && session.Mode == SessionModeObserver { + if !checkBlacklist || !sm.isSessionBlacklisted(id) { + return id + } + } + } + + // If still no primary and there are pending sessions (edge case: all sessions are pending) + // This can happen if RequireApproval was enabled but primary left + for id, session := range sm.sessions { + if id != excludeSessionID && session.Mode == SessionModePending { + if !checkBlacklist || !sm.isSessionBlacklisted(id) { + return id + } + } + } + + return "" // No eligible session found +} + +func (sm *SessionManager) findNextSessionToPromoteExcludingIgnoreBlacklist(excludeSessionID string) string { + return sm.findNextSessionToPromoteExcluding(excludeSessionID, false) +} + +func (sm *SessionManager) removeFromQueue(sessionID string) { + // In-place removal is more efficient + for i, id := range sm.queueOrder { + if id == sessionID { + sm.queueOrder = append(sm.queueOrder[:i], sm.queueOrder[i+1:]...) + return + } + } +} + +func (sm *SessionManager) clearInputState() { + // Clear keyboard state + if gadget != nil { + gadget.KeyboardReport(0, []byte{0, 0, 0, 0, 0, 0}) + } +} + +// getCurrentPrimaryTimeout returns the current primary timeout duration +func (sm *SessionManager) getCurrentPrimaryTimeout() time.Duration { + // Use session settings if available + if currentSessionSettings != nil { + if currentSessionSettings.PrimaryTimeout == 0 { + // 0 means disabled - return a very large duration + return 24 * time.Hour + } else if currentSessionSettings.PrimaryTimeout > 0 { + return time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second + } + } + // Fall back to config or default + return sm.primaryTimeout +} + +// getSessionTrustScore calculates a trust score for session selection during emergency promotion +func (sm *SessionManager) getSessionTrustScore(sessionID string) int { + session, exists := sm.sessions[sessionID] + if !exists { + return -1000 // Session doesn't exist + } + + score := 0 + now := time.Now() + + // Longer session duration = more trust (up to 100 points for 100+ minutes) + sessionAge := now.Sub(session.CreatedAt) + score += int(sessionAge.Minutes()) + if score > 100 { + score = 100 // Cap age bonus at 100 points + } + + // Recently successful primary sessions get higher trust + if sm.lastPrimaryID == sessionID { + score += 50 + } + + // Observer mode is more trustworthy than queued/pending for emergency promotion + switch session.Mode { + case SessionModeObserver: + score += 20 + case SessionModeQueued: + score += 10 + case SessionModePending: + // Pending sessions get no bonus and are less preferred + score += 0 + } + + // Check if session has nickname when required (shows engagement) + if currentSessionSettings != nil && currentSessionSettings.RequireNickname { + if session.Nickname != "" { + score += 15 + } else { + score -= 30 // Penalize sessions without required nickname + } + } + + return score +} + +// findMostTrustedSessionForEmergency finds the most trustworthy session for emergency promotion +func (sm *SessionManager) findMostTrustedSessionForEmergency() string { + bestSessionID := "" + bestScore := -1 + + for sessionID, session := range sm.sessions { + // Skip if blacklisted, primary, or not eligible modes + if sm.isSessionBlacklisted(sessionID) || + session.Mode == SessionModePrimary || + (session.Mode != SessionModeObserver && session.Mode != SessionModeQueued) { + continue + } + + score := sm.getSessionTrustScore(sessionID) + if score > bestScore { + bestScore = score + bestSessionID = sessionID + } + } + + // Log the selection decision for audit trail + if bestSessionID != "" { + sm.logger.Info(). + Str("selectedSession", bestSessionID). + Int("trustScore", bestScore). + Msg("Selected most trusted session for emergency promotion") + } + + return bestSessionID +} + +// extractBrowserFromUserAgent extracts browser name from user agent string +func extractBrowserFromUserAgent(userAgent string) *string { + ua := strings.ToLower(userAgent) + + // Check for common browsers (order matters - Chrome contains Safari, etc.) + if strings.Contains(ua, "edg/") || strings.Contains(ua, "edge") { + return &BrowserEdge + } + if strings.Contains(ua, "firefox") { + return &BrowserFirefox + } + if strings.Contains(ua, "chrome") { + return &BrowserChrome + } + if strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome") { + return &BrowserSafari + } + if strings.Contains(ua, "opera") || strings.Contains(ua, "opr/") { + return &BrowserOpera + } + + return &BrowserUnknown +} + +// generateAutoNickname creates a user-friendly auto-generated nickname +func generateAutoNickname(session *Session) string { + // Use browser type from session, fallback to "user" if not set + browser := "user" + if session.Browser != nil { + browser = *session.Browser + } + + // Use last 4 chars of session ID for uniqueness (lowercase) + sessionID := strings.ToLower(session.ID) + shortID := sessionID[len(sessionID)-4:] + + // Generate contextual lowercase nickname + return fmt.Sprintf("u-%s-%s", browser, shortID) +} + +// generateNicknameFromUserAgent creates a nickname from user agent (for frontend use) +func generateNicknameFromUserAgent(userAgent string) string { + // Extract browser info + browserPtr := extractBrowserFromUserAgent(userAgent) + browser := "user" + if browserPtr != nil { + browser = *browserPtr + } + + // Generate a random 4-character ID (lowercase) + shortID := strings.ToLower(fmt.Sprintf("%04x", time.Now().UnixNano()%0xFFFF)) + + // Generate contextual lowercase nickname + return fmt.Sprintf("u-%s-%s", browser, shortID) +} + +// ensureNickname ensures session has a nickname, auto-generating if needed +func (sm *SessionManager) ensureNickname(session *Session) { + // Skip if session already has a nickname + if session.Nickname != "" { + return + } + + // Skip if nickname is required (user must set manually) + if currentSessionSettings != nil && currentSessionSettings.RequireNickname { + return + } + + // Auto-generate nickname + session.Nickname = generateAutoNickname(session) + + sm.logger.Debug(). + Str("sessionID", session.ID). + Str("autoNickname", session.Nickname). + Msg("Auto-generated nickname for session") +} + +// updateAllSessionNicknames updates nicknames for all sessions when settings change +func (sm *SessionManager) updateAllSessionNicknames() { + sm.mu.Lock() + defer sm.mu.Unlock() + + updated := 0 + for _, session := range sm.sessions { + oldNickname := session.Nickname + sm.ensureNickname(session) + if session.Nickname != oldNickname { + updated++ + } + } + + if updated > 0 { + sm.logger.Info(). + Int("updatedSessions", updated). + Msg("Auto-generated nicknames for sessions after settings change") + + // Broadcast the update + go sm.broadcastSessionListUpdate() + } +} + +func (sm *SessionManager) broadcastSessionListUpdate() { + // Throttle broadcasts to prevent DoS + broadcastMutex.Lock() + if time.Since(lastBroadcast) < broadcastDelay { + broadcastMutex.Unlock() + return // Skip this broadcast to prevent storm + } + lastBroadcast = time.Now() + broadcastMutex.Unlock() + + // Must be called in a goroutine to avoid deadlock + // Get all sessions first - use read lock only, no validation during broadcasts + sm.mu.RLock() + + // Build session infos and collect active sessions in one pass + infos := make([]SessionData, 0, len(sm.sessions)) + activeSessions := make([]*Session, 0, len(sm.sessions)) + + for _, session := range sm.sessions { + infos = append(infos, SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }) + + // Only collect sessions ready for broadcast + if session.RPCChannel != nil { + activeSessions = append(activeSessions, session) + } + } + + sm.mu.RUnlock() + + // Now send events without holding lock + for _, session := range activeSessions { + // Per-session throttling to prevent broadcast storms + if time.Since(session.LastBroadcast) < 50*time.Millisecond { + continue + } + session.LastBroadcast = time.Now() + event := SessionsUpdateEvent{ + Sessions: infos, + YourMode: session.Mode, + } + writeJSONRPCEvent("sessionsUpdated", event, session) + } +} + +// Shutdown stops the session manager and cleans up resources +func (sm *SessionManager) Shutdown() { + if sm.cleanupCancel != nil { + sm.cleanupCancel() + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + // Clean up all sessions + for id := range sm.sessions { + delete(sm.sessions, id) + } +} + +func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) // Check every second for grace periods + defer ticker.Stop() + + pendingTimeout := 1 * time.Minute // Reduced from 5 minutes to prevent DoS + validationCounter := 0 // Counter for periodic validateSinglePrimary calls + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sm.mu.Lock() + now := time.Now() + needsBroadcast := false + + // Check for expired grace periods and promote if needed + for sessionID, graceTime := range sm.reconnectGrace { + if now.After(graceTime) { + delete(sm.reconnectGrace, sessionID) + + wasHoldingPrimarySlot := (sm.lastPrimaryID == sessionID) + + // Check if this expired session was the primary holding the slot + if wasHoldingPrimarySlot { + // The primary didn't reconnect in time, now we can clear the slot and promote + sm.primarySessionID = "" + sm.lastPrimaryID = "" + needsBroadcast = true + + sm.logger.Info(). + Str("expiredSessionID", sessionID). + Msg("Primary session grace period expired - slot now available") + + // Always try to promote when possible - approval is only for new pending sessions + // Use enhanced emergency promotion system for better security + isEmergencyPromotion := false + var promotedSessionID string + + // Check if this is an emergency scenario (RequireApproval enabled) + if currentSessionSettings != nil && currentSessionSettings.RequireApproval { + isEmergencyPromotion = true + + // Rate limiting for emergency promotions + if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second { + sm.logger.Warn(). + Str("expiredSessionID", sessionID). + Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)). + Msg("Emergency promotion rate limit exceeded - potential attack") + continue // Skip this grace period expiration + } + + // Limit consecutive emergency promotions + if sm.consecutiveEmergencyPromotions >= 3 { + sm.logger.Error(). + Str("expiredSessionID", sessionID). + Int("consecutiveCount", sm.consecutiveEmergencyPromotions). + Msg("Too many consecutive emergency promotions - blocking for security") + continue // Skip this grace period expiration + } + + promotedSessionID = sm.findMostTrustedSessionForEmergency() + } else { + // Normal promotion - reset consecutive counter + sm.consecutiveEmergencyPromotions = 0 + promotedSessionID = sm.findNextSessionToPromote() + } + + if promotedSessionID != "" { + // Determine reason and log appropriately + reason := "grace_expiration_promotion" + if isEmergencyPromotion { + reason = "emergency_promotion_deadlock_prevention" + sm.lastEmergencyPromotion = now + sm.consecutiveEmergencyPromotions++ + + // Enhanced logging for emergency promotions + sm.logger.Warn(). + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Bool("requireApproval", true). + Int("consecutiveEmergencyPromotions", sm.consecutiveEmergencyPromotions). + Int("trustScore", sm.getSessionTrustScore(promotedSessionID)). + Msg("EMERGENCY: Bypassing approval requirement to prevent deadlock") + } + + err := sm.transferPrimaryRole("", promotedSessionID, reason, "primary grace period expired") + if err == nil { + logEvent := sm.logger.Info() + if isEmergencyPromotion { + logEvent = sm.logger.Warn() + } + logEvent. + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Str("reason", reason). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Auto-promoted session after primary grace period expiration") + } else { + sm.logger.Error(). + Err(err). + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Str("reason", reason). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Failed to promote session after grace period expiration") + } + } else { + logLevel := sm.logger.Info() + if isEmergencyPromotion { + logLevel = sm.logger.Error() // Emergency with no eligible sessions is critical + } + logLevel. + Str("expiredSessionID", sessionID). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Primary grace period expired but no eligible sessions to promote") + } + } else { + // Non-primary session grace period expired - just cleanup + sm.logger.Debug(). + Str("expiredSessionID", sessionID). + Msg("Non-primary session grace period expired") + } + + // Also clean up reconnect info for expired sessions + delete(sm.reconnectInfo, sessionID) + } + } + + // Clean up pending sessions that have timed out (DoS protection) + for id, session := range sm.sessions { + if session.Mode == SessionModePending && + now.Sub(session.CreatedAt) > pendingTimeout { + websocketLogger.Info(). + Str("sessionId", id). + Dur("age", now.Sub(session.CreatedAt)). + Msg("Removing timed-out pending session") + delete(sm.sessions, id) + needsBroadcast = true + } + } + + // Check primary session timeout (every 30 iterations = 30 seconds) + if sm.primarySessionID != "" { + if primary, exists := sm.sessions[sm.primarySessionID]; exists { + currentTimeout := sm.getCurrentPrimaryTimeout() + if now.Sub(primary.LastActive) > currentTimeout { + timedOutSessionID := primary.ID + primary.Mode = SessionModeObserver + sm.primarySessionID = "" + + // Use enhanced emergency promotion system for timeout scenarios too + isEmergencyPromotion := false + var promotedSessionID string + + // Check if this requires emergency promotion due to approval requirements + if currentSessionSettings != nil && currentSessionSettings.RequireApproval { + isEmergencyPromotion = true + + // Rate limiting for emergency promotions + if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second { + sm.logger.Warn(). + Str("timedOutSessionID", timedOutSessionID). + Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)). + Msg("Emergency promotion rate limit exceeded during timeout - potential attack") + continue // Skip this timeout + } + + // Use trust-based selection but exclude the timed-out session + bestSessionID := "" + bestScore := -1 + for id, session := range sm.sessions { + if id != timedOutSessionID && + !sm.isSessionBlacklisted(id) && + (session.Mode == SessionModeObserver || session.Mode == SessionModeQueued) { + score := sm.getSessionTrustScore(id) + if score > bestScore { + bestScore = score + bestSessionID = id + } + } + } + promotedSessionID = bestSessionID + } else { + // Normal timeout promotion - find any observer except the timed-out one + for id, session := range sm.sessions { + if id != timedOutSessionID && session.Mode == SessionModeObserver && !sm.isSessionBlacklisted(id) { + promotedSessionID = id + break + } + } + } + + // If found a session to promote + if promotedSessionID != "" { + reason := "timeout_promotion" + if isEmergencyPromotion { + reason = "emergency_timeout_promotion" + sm.lastEmergencyPromotion = now + sm.consecutiveEmergencyPromotions++ + + // Enhanced logging for emergency timeout promotions + sm.logger.Warn(). + Str("timedOutSessionID", timedOutSessionID). + Str("promotedSessionID", promotedSessionID). + Bool("requireApproval", true). + Int("trustScore", sm.getSessionTrustScore(promotedSessionID)). + Msg("EMERGENCY: Timeout promotion bypassing approval requirement") + } + + err := sm.transferPrimaryRole(timedOutSessionID, promotedSessionID, reason, "primary session timeout") + if err == nil { + needsBroadcast = true + logEvent := sm.logger.Info() + if isEmergencyPromotion { + logEvent = sm.logger.Warn() + } + logEvent. + Str("timedOutSessionID", timedOutSessionID). + Str("promotedSessionID", promotedSessionID). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Auto-promoted session after primary timeout") + } + } + } + } else { + // Primary session no longer exists, clear it + sm.primarySessionID = "" + needsBroadcast = true + } + } + + // Periodic validateSinglePrimary to catch deadlock states + validationCounter++ + if validationCounter >= 10 { // Every 10 seconds + validationCounter = 0 + sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states") + sm.validateSinglePrimary() + } + + sm.mu.Unlock() + + // Broadcast outside of lock if needed + if needsBroadcast { + go sm.broadcastSessionListUpdate() + } + } + } +} + +// Global session manager instance +var sessionManager = NewSessionManager(websocketLogger) + +// Global session settings - references config.SessionSettings for persistence +var currentSessionSettings *SessionSettings \ No newline at end of file diff --git a/session_permissions.go b/session_permissions.go new file mode 100644 index 00000000..35dcd43d --- /dev/null +++ b/session_permissions.go @@ -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 +} \ No newline at end of file diff --git a/terminal.go b/terminal.go index e06e5cdc..ea13087c 100644 --- a/terminal.go +++ b/terminal.go @@ -16,9 +16,16 @@ type TerminalSize struct { Cols int `json:"cols"` } -func handleTerminalChannel(d *webrtc.DataChannel) { +func handleTerminalChannel(d *webrtc.DataChannel, session *Session) { scopedLogger := terminalLogger.With(). - Uint16("data_channel_id", *d.ID()).Logger() + Uint16("data_channel_id", *d.ID()). + Str("session_id", session.ID).Logger() + + // Check terminal access permission + if !session.HasPermission(PermissionTerminalAccess) { + handlePermissionDeniedChannel(d, "Terminal access denied: Permission required") + return + } var ptmx *os.File var cmd *exec.Cmd diff --git a/ui/src/api/sessionApi.ts b/ui/src/api/sessionApi.ts new file mode 100644 index 00000000..bbd93b6e --- /dev/null +++ b/ui/src/api/sessionApi.ts @@ -0,0 +1,113 @@ +import { SessionInfo } from "@/stores/sessionStore"; + +export const sessionApi = { + getSessions: async (sendFn: Function): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessions", {}, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result || []); + } + }); + }); + }, + + getSessionInfo: async (sendFn: Function, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessionInfo", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result); + } + }); + }); + }, + + requestPrimary: async (sendFn: Function, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => { + return new Promise((resolve, reject) => { + sendFn("requestPrimary", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result); + } + }); + }); + }, + + releasePrimary: async (sendFn: Function, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("releasePrimary", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + transferPrimary: async ( + sendFn: Function, + fromId: string, + toId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("transferPrimary", { fromId, toId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + updateNickname: async ( + sendFn: Function, + sessionId: string, + nickname: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("updateSessionNickname", { sessionId, nickname }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + approveNewSession: async ( + sendFn: Function, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + denyNewSession: async ( + sendFn: Function, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + } +}; \ No newline at end of file diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx new file mode 100644 index 00000000..7d0b6e46 --- /dev/null +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { XCircleIcon } from "@heroicons/react/24/outline"; +import { Button } from "./Button"; +import { DEVICE_API, CLOUD_API } from "@/ui.config"; +import { isOnDevice } from "@/main"; +import { useUserStore } from "@/hooks/stores"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import api from "@/api"; + +interface AccessDeniedOverlayProps { + show: boolean; + message?: string; + onRetry?: () => void; +} + +export default function AccessDeniedOverlay({ + show, + message = "Your session access was denied", + onRetry +}: AccessDeniedOverlayProps) { + const navigate = useNavigate(); + const setUser = useUserStore(state => state.setUser); + const { clearSession } = useSessionStore(); + const { clearNickname } = useSharedSessionStore(); + const [countdown, setCountdown] = useState(10); + + const handleLogout = async () => { + try { + const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`; + const res = await api.POST(logoutUrl); + if (!res.ok) { + console.warn("Logout API call failed, but continuing with local cleanup"); + } + } catch (error) { + console.error("Logout API call failed:", error); + } + + // Always clear local state and navigate, regardless of API call result + setUser(null); + clearSession(); + clearNickname(); + navigate("/"); + }; + + useEffect(() => { + if (!show) return; + + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(timer); + // Auto-redirect with proper logout + handleLogout(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [show]); + + if (!show) return null; + + return ( +
+
+
+ +
+

+ Access Denied +

+

+ {message} +

+
+
+ +
+
+

+ The primary session has denied your access request. This could be for security reasons + or because the session is restricted. +

+
+ +

+ Redirecting in {countdown} seconds... +

+ +
+ {onRetry && ( +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 4f79d7ed..c5d5e590 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -2,8 +2,8 @@ import { MdOutlineContentPasteGo } from "react-icons/md"; import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu"; import { FaKeyboard } from "react-icons/fa6"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; -import { Fragment, useCallback, useRef } from "react"; -import { CommandLineIcon } from "@heroicons/react/20/solid"; +import { Fragment, useCallback, useRef, useEffect } from "react"; +import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid"; import { Button } from "@components/Button"; import { @@ -18,7 +18,11 @@ import PasteModal from "@/components/popovers/PasteModal"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import MountPopopover from "@/components/popovers/MountPopover"; import ExtensionPopover from "@/components/popovers/ExtensionPopover"; +import SessionPopover from "@/components/popovers/SessionPopover"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; export default function Actionbar({ requestFullscreen, @@ -33,6 +37,37 @@ export default function Actionbar({ state => state.remoteVirtualMediaState, ); const { developerMode } = useSettingsStore(); + const { currentMode, sessions, setSessions } = useSessionStore(); + const { rpcDataChannel } = useRTCStore(); + const { hasPermission } = usePermissions(); + + // Fetch sessions on mount if we have an RPC channel + useEffect(() => { + if (rpcDataChannel?.readyState === "open" && sessions.length === 0) { + const id = Math.random().toString(36).substring(2); + const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id }); + + const handler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data); + if (response.id === id && response.result) { + setSessions(response.result); + rpcDataChannel.removeEventListener("message", handler); + } + } catch (error) { + // Ignore parse errors for non-JSON messages + } + }; + + rpcDataChannel.addEventListener("message", handler); + rpcDataChannel.send(message); + + // Clean up after timeout + setTimeout(() => { + rpcDataChannel.removeEventListener("message", handler); + }, 5000); + } + }, [rpcDataChannel?.readyState]); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -44,7 +79,6 @@ export default function Actionbar({ if (!open) { setTimeout(() => { setDisableVideoFocusTrap(false); - console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } @@ -69,179 +103,239 @@ export default function Actionbar({ onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")} /> )} - - + {hasPermission(Permission.PASTE) && ( + + + + )} + + +
+
+ + { + 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} + /> +
+ {error ? ( +

{error}

+ ) : ( +
+

+ {nickname.trim() === "" && generatedNickname + ? `Leave empty to use: ${generatedNickname}` + : "2-30 characters, letters, numbers, spaces, and - _ . @ allowed"} +

+
+ )} + + {nickname.length}/30 + +
+
+ + {isNicknameRequired && ( +
+

+ Required: A nickname is required by the administrator to help identify sessions. +

+
+ )} + +
+
+
+ + + + + ); +} \ No newline at end of file diff --git a/ui/src/components/PendingApprovalOverlay.tsx b/ui/src/components/PendingApprovalOverlay.tsx new file mode 100644 index 00000000..6d96ab76 --- /dev/null +++ b/ui/src/components/PendingApprovalOverlay.tsx @@ -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 ( +
+
+
+ + +
+

+ Awaiting Approval{dots} +

+

+ Your session is pending approval from the primary session +

+
+ +
+

+ The primary user will receive a notification to approve or deny your access. + This typically takes less than 30 seconds. +

+
+ +
+
+ Waiting for response from primary session +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/SessionControlPanel.tsx b/ui/src/components/SessionControlPanel.tsx new file mode 100644 index 00000000..2c29e841 --- /dev/null +++ b/ui/src/components/SessionControlPanel.tsx @@ -0,0 +1,139 @@ +import { useSessionStore } from "@/stores/sessionStore"; +import { sessionApi } from "@/api/sessionApi"; +import { Button } from "@/components/Button"; +import { + LockClosedIcon, + LockOpenIcon, + ClockIcon +} from "@heroicons/react/16/solid"; +import clsx from "clsx"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +interface SessionControlPanelProps { + sendFn: Function; + className?: string; +} + +export default function SessionControlPanel({ sendFn, className }: SessionControlPanelProps) { + const { + currentSessionId, + currentMode, + sessions, + isRequestingPrimary, + setRequestingPrimary, + setSessionError, + canRequestPrimary + } = useSessionStore(); + const { hasPermission } = usePermissions(); + + + const handleRequestPrimary = async () => { + if (!currentSessionId || isRequestingPrimary) return; + + setRequestingPrimary(true); + setSessionError(null); + + try { + const result = await sessionApi.requestPrimary(sendFn, currentSessionId); + + if (result.status === "success") { + if (result.mode === "primary") { + // Immediately became primary + setRequestingPrimary(false); + } else if (result.mode === "queued") { + // Request sent, waiting for approval + // Keep isRequestingPrimary true to show waiting state + } + } else if (result.status === "error") { + setSessionError(result.message || "Failed to request primary control"); + setRequestingPrimary(false); + } + } catch (error: any) { + setSessionError(error.message); + console.error("Failed to request primary control:", error); + setRequestingPrimary(false); + } + }; + + const handleReleasePrimary = async () => { + if (!currentSessionId || currentMode !== "primary") return; + + try { + await sessionApi.releasePrimary(sendFn, currentSessionId); + } catch (error: any) { + setSessionError(error.message); + console.error("Failed to release primary control:", error); + } + }; + + const canReleasePrimary = () => { + const otherEligibleSessions = sessions.filter( + s => s.id !== currentSessionId && (s.mode === "observer" || s.mode === "queued") + ); + return otherEligibleSessions.length > 0; + }; + + + return ( +
+ {/* Current session controls */} +
+

+ Session Control +

+ + {hasPermission(Permission.SESSION_RELEASE_PRIMARY) && ( +
+
+ )} + + {hasPermission(Permission.SESSION_REQUEST_PRIMARY) && ( + <> + {isRequestingPrimary ? ( +
+ + + Waiting for approval from primary session... + +
+ ) : ( +
+ +
+ ); +} \ No newline at end of file diff --git a/ui/src/components/SessionsList.tsx b/ui/src/components/SessionsList.tsx new file mode 100644 index 00000000..bba58779 --- /dev/null +++ b/ui/src/components/SessionsList.tsx @@ -0,0 +1,149 @@ +import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import clsx from "clsx"; +import { formatters } from "@/utils"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +interface Session { + id: string; + mode: string; + nickname?: string; + identity?: string; + source?: string; + createdAt?: string; +} + +interface SessionsListProps { + sessions: Session[]; + currentSessionId?: string; + onEditNickname?: (sessionId: string) => void; + onApprove?: (sessionId: string) => void; + onDeny?: (sessionId: string) => void; + onTransfer?: (sessionId: string) => void; + formatDuration?: (createdAt: string) => string; +} + +export default function SessionsList({ + sessions, + currentSessionId, + onEditNickname, + onApprove, + onDeny, + onTransfer, + formatDuration = (createdAt: string) => formatters.timeAgo(new Date(createdAt)) || "" +}: SessionsListProps) { + const { hasPermission } = usePermissions(); + return ( +
+ {sessions.map(session => ( +
+
+
+ + {session.id === currentSessionId && ( + (You) + )} +
+
+ + {session.createdAt ? formatDuration(session.createdAt) : ""} + + {/* Show approve/deny for pending sessions if user has permission */} + {session.mode === "pending" && hasPermission(Permission.SESSION_APPROVE) && onApprove && onDeny && ( +
+ + +
+ )} + {/* Show Transfer button if user has permission to transfer */} + {hasPermission(Permission.SESSION_TRANSFER) && session.mode === "observer" && session.id !== currentSessionId && onTransfer && ( + + )} + {/* Allow users with session manage permission to edit any nickname, or anyone to edit their own */} + {onEditNickname && (hasPermission(Permission.SESSION_MANAGE) || session.id === currentSessionId) && ( + + )} +
+
+ +
+ {session.nickname && ( +

+ {session.nickname} +

+ )} + {session.identity && ( +

+ {session.source === "cloud" ? "☁️ " : ""}{session.identity} +

+ )} + {session.mode === "pending" && ( +

+ Awaiting approval +

+ )} +
+
+ ))} +
+ ); +} + +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 ( + + {mode} + + ); +} \ No newline at end of file diff --git a/ui/src/components/UnifiedSessionRequestDialog.tsx b/ui/src/components/UnifiedSessionRequestDialog.tsx new file mode 100644 index 00000000..1d1f1b6c --- /dev/null +++ b/ui/src/components/UnifiedSessionRequestDialog.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState } from "react"; +import { XMarkIcon, UserIcon, GlobeAltIcon, ComputerDesktopIcon } from "@heroicons/react/20/solid"; +import { Button } from "./Button"; + +type RequestType = "session_approval" | "primary_control"; + +interface UnifiedSessionRequest { + id: string; // sessionId or requestId + type: RequestType; + source: "local" | "cloud" | string; // Allow string for IP addresses + identity?: string; + nickname?: string; +} + +interface UnifiedSessionRequestDialogProps { + request: UnifiedSessionRequest | null; + onApprove: (id: string) => void | Promise; + onDeny: (id: string) => void | Promise; + onClose: () => void; +} + +export default function UnifiedSessionRequestDialog({ + request, + onApprove, + onDeny, + onClose +}: UnifiedSessionRequestDialogProps) { + const [timeRemaining, setTimeRemaining] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [hasTimedOut, setHasTimedOut] = useState(false); + + useEffect(() => { + if (!request) return; + + const isSessionApproval = request.type === "session_approval"; + const initialTime = isSessionApproval ? 60 : 0; // 60s for session approval, no timeout for primary control + + setTimeRemaining(initialTime); + setIsProcessing(false); + setHasTimedOut(false); + + // Only start timer for session approval requests + if (isSessionApproval) { + const timer = setInterval(() => { + setTimeRemaining(prev => { + const newTime = prev - 1; + if (newTime <= 0) { + clearInterval(timer); + setHasTimedOut(true); + return 0; + } + return newTime; + }); + }, 1000); + + return () => clearInterval(timer); + } + }, [request?.id, request?.type]); // Only depend on stable properties + + // Handle auto-deny when timeout occurs + useEffect(() => { + if (hasTimedOut && !isProcessing && request) { + setIsProcessing(true); + Promise.resolve(onDeny(request.id)) + .catch(error => { + console.error("Failed to auto-deny request:", error); + }) + .finally(() => { + onClose(); + }); + } + }, [hasTimedOut, isProcessing, request, onDeny, onClose]); + + if (!request) return null; + + const isSessionApproval = request.type === "session_approval"; + const isPrimaryControl = request.type === "primary_control"; + + // Determine if source is cloud, local, or IP address + const getSourceInfo = () => { + if (request.source === "cloud") { + return { + type: "cloud", + label: "Cloud Session", + icon: GlobeAltIcon, + iconColor: "text-blue-500" + }; + } else if (request.source === "local") { + return { + type: "local", + label: "Local Session", + icon: ComputerDesktopIcon, + iconColor: "text-green-500" + }; + } else { + // Assume it's an IP address or hostname + return { + type: "ip", + label: request.source, + icon: ComputerDesktopIcon, + iconColor: "text-green-500" + }; + } + }; + + const sourceInfo = getSourceInfo(); + + const getTitle = () => { + if (isSessionApproval) return "New Session Request"; + if (isPrimaryControl) return "Primary Control Request"; + return "Session Request"; + }; + + const getDescription = () => { + if (isSessionApproval) return "A new session is attempting to connect to this device:"; + if (isPrimaryControl) return "A user is requesting primary control of this session:"; + return "A user is making a request:"; + }; + + return ( +
+
+
+

+ {getTitle()} +

+ +
+ +
+

+ {getDescription()} +

+ +
+ {/* Session type - always show with icon for both session approval and primary control */} +
+ + + {sourceInfo.type === "cloud" ? "Cloud Session" : + sourceInfo.type === "local" ? "Local Session" : + `Local Session`} + + {sourceInfo.type === "ip" && ( + + ({sourceInfo.label}) + + )} +
+ + {/* Nickname - always show with icon for consistency */} + {request.nickname && ( +
+ + + Nickname:{" "} + {request.nickname} + +
+ )} + + {/* Identity/User */} + {request.identity && ( +
+ {isSessionApproval ? ( +

Identity: {request.identity}

+ ) : ( +

+ User:{" "} + {request.identity} +

+ )} +
+ )} +
+ + {/* Security Note - only for session approval */} + {isSessionApproval && ( +
+

+ Security Note: Only approve sessions you recognize. + Approved sessions will have observer access and can request primary control. +

+
+ )} + + {/* Auto-deny timer - only for session approval */} + {isSessionApproval && ( +
+

+ Auto-deny in {timeRemaining} seconds +

+
+ )} + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 64452bf8..1187f44e 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -14,6 +14,8 @@ import { useSettingsStore, useVideoStore, } from "@/hooks/stores"; +import { useSessionStore } from "@/stores/sessionStore"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; import useMouse from "@/hooks/useMouse"; import { @@ -35,6 +37,8 @@ export default function WebRTCVideo() { // Store hooks const settings = useSettingsStore(); + const { currentMode } = useSessionStore(); + const { hasPermission } = usePermissions(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { getRelMouseMoveHandler, @@ -214,29 +218,47 @@ export default function WebRTCVideo() { document.addEventListener("fullscreenchange", handleFullscreenChange); }, [releaseKeyboardLock]); - const absMouseMoveHandler = useMemo( - () => getAbsMouseMoveHandler({ + const absMouseMoveHandler = useMemo(() => { + const handler = getAbsMouseMoveHandler({ videoClientWidth, videoClientHeight, videoWidth, videoHeight, - }), - [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight], - ); + }); + return (e: MouseEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [currentMode, getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight]); - const relMouseMoveHandler = useMemo( - () => getRelMouseMoveHandler(), - [getRelMouseMoveHandler], - ); + const relMouseMoveHandler = useMemo(() => { + const handler = getRelMouseMoveHandler(); + return (e: MouseEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [currentMode, getRelMouseMoveHandler]); - const mouseWheelHandler = useMemo( - () => getMouseWheelHandler(), - [getMouseWheelHandler], - ); + const mouseWheelHandler = useMemo(() => { + const handler = getMouseWheelHandler(); + return (e: WheelEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [currentMode, getMouseWheelHandler]); const keyDownHandler = useCallback( (e: KeyboardEvent) => { e.preventDefault(); + + // Only allow input if user has keyboard permission + if (!hasPermission(Permission.KEYBOARD_INPUT)) { + return; + } + if (e.repeat) return; const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -252,11 +274,9 @@ export default function WebRTCVideo() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { - console.debug(`Forcing the meta key release of associated key: ${hidKey}`); handleKeyPress(hidKey, false); }, 10); } - console.debug(`Key down: ${hidKey}`); handleKeyPress(hidKey, true); if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { @@ -264,17 +284,22 @@ export default function WebRTCVideo() { // we'll never see the keyup event because the browser is going to lose // focus so set a deferred keyup after a short delay setTimeout(() => { - console.debug(`Forcing the left meta key release`); handleKeyPress(hidKey, false); }, 100); } }, - [handleKeyPress, isKeyboardLockActive], + [currentMode, handleKeyPress, isKeyboardLockActive], ); const keyUpHandler = useCallback( async (e: KeyboardEvent) => { e.preventDefault(); + + // Only allow input if user has keyboard permission + if (!hasPermission(Permission.KEYBOARD_INPUT)) { + return; + } + const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -283,10 +308,9 @@ export default function WebRTCVideo() { return; } - console.debug(`Key up: ${hidKey}`); handleKeyPress(hidKey, false); }, - [handleKeyPress], + [currentMode, handleKeyPress], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -297,7 +321,6 @@ export default function WebRTCVideo() { // Fix only works in chrome based browsers. if (e.code === "Space") { if (videoElm.current.paused) { - console.debug("Force playing video"); videoElm.current.play(); } } @@ -556,7 +579,7 @@ export default function WebRTCVideo() { )}
- + {hasPermission(Permission.KEYBOARD_INPUT) && } diff --git a/ui/src/components/popovers/SessionPopover.tsx b/ui/src/components/popovers/SessionPopover.tsx new file mode 100644 index 00000000..a078b429 --- /dev/null +++ b/ui/src/components/popovers/SessionPopover.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect } from "react"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { + UserGroupIcon, + ArrowPathIcon, + PencilIcon, +} from "@heroicons/react/20/solid"; +import clsx from "clsx"; +import SessionControlPanel from "@/components/SessionControlPanel"; +import NicknameModal from "@/components/NicknameModal"; +import SessionsList, { SessionModeBadge } from "@/components/SessionsList"; +import { sessionApi } from "@/api/sessionApi"; + +export default function SessionPopover() { + const { + currentSessionId, + currentMode, + sessions, + sessionError, + setSessions, + } = useSessionStore(); + const { setNickname } = useSharedSessionStore(); + + const [isRefreshing, setIsRefreshing] = useState(false); + const [showNicknameModal, setShowNicknameModal] = useState(false); + const [editingSessionId, setEditingSessionId] = useState(null); + + const { send } = useJsonRpc(); + + // Adapter function to match existing callback pattern + const sendRpc = (method: string, params: any, callback?: (response: any) => void) => { + send(method, params, (response) => { + if (callback) callback(response); + }); + }; + + const handleRefresh = async () => { + if (isRefreshing) return; + + setIsRefreshing(true); + try { + const refreshedSessions = await sessionApi.getSessions(sendRpc); + setSessions(refreshedSessions); + } catch (error) { + console.error("Failed to refresh sessions:", error); + } finally { + setIsRefreshing(false); + } + }; + + // Fetch sessions on mount + useEffect(() => { + if (sessions.length === 0) { + sessionApi.getSessions(sendRpc) + .then(sessions => setSessions(sessions)) + .catch(error => console.error("Failed to fetch sessions:", error)); + } + }, []); + + return ( +
+ {/* Header */} +
+
+
+ +

+ Session Management +

+
+ +
+
+ + {/* Session Error */} + {sessionError && ( +
+

{sessionError}

+
+ )} + + {/* Current Session */} +
+
+
+
+ Your Session + +
+ +
+ + {currentSessionId && ( + <> + {/* Display current session nickname if exists */} + {sessions.find(s => s.id === currentSessionId)?.nickname && ( +
+ Nickname: + + {sessions.find(s => s.id === currentSessionId)?.nickname} + +
+ )} + +
+ +
+ + )} +
+
+ + {/* Active Sessions List */} +
+
+ Active Sessions ({sessions.length}) +
+ + {sessions.length > 0 ? ( + { + setEditingSessionId(sessionId); + setShowNicknameModal(true); + }} + onApprove={(sessionId) => { + sendRpc("approveNewSession", { sessionId }, (response: any) => { + if (response.error) { + console.error("Failed to approve session:", response.error); + } else { + handleRefresh(); + } + }); + }} + onDeny={(sessionId) => { + sendRpc("denyNewSession", { sessionId }, (response: any) => { + if (response.error) { + console.error("Failed to deny session:", response.error); + } else { + handleRefresh(); + } + }); + }} + onTransfer={async (sessionId) => { + try { + await sessionApi.transferPrimary(sendRpc, currentSessionId!, sessionId); + handleRefresh(); + } catch (error) { + console.error("Failed to transfer primary:", error); + } + }} + /> + ) : ( +

No active sessions

+ )} +
+ + 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); + }} + /> +
+ ); +} + diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..180fb985 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -329,6 +329,12 @@ export interface SettingsState { developerMode: boolean; setDeveloperMode: (enabled: boolean) => void; + requireSessionNickname: boolean; + setRequireSessionNickname: (required: boolean) => void; + + requireSessionApproval: boolean; + setRequireSessionApproval: (required: boolean) => void; + displayRotation: string; setDisplayRotation: (rotation: string) => void; @@ -369,6 +375,12 @@ export const useSettingsStore = create( developerMode: false, setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }), + requireSessionNickname: false, + setRequireSessionNickname: (required: boolean) => set({ requireSessionNickname: required }), + + requireSessionApproval: true, + setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }), + displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5c52d59c..91965c74 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -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 }; } diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts new file mode 100644 index 00000000..ca4fb5ff --- /dev/null +++ b/ui/src/hooks/usePermissions.ts @@ -0,0 +1,164 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; + +// Permission types matching backend +export enum Permission { + // Video/Display permissions + VIDEO_VIEW = "video.view", + + // Input permissions + KEYBOARD_INPUT = "keyboard.input", + MOUSE_INPUT = "mouse.input", + PASTE = "clipboard.paste", + + // Session management permissions + SESSION_TRANSFER = "session.transfer", + SESSION_APPROVE = "session.approve", + SESSION_KICK = "session.kick", + SESSION_REQUEST_PRIMARY = "session.request_primary", + SESSION_RELEASE_PRIMARY = "session.release_primary", + SESSION_MANAGE = "session.manage", + + // Mount/Media permissions + MOUNT_MEDIA = "mount.media", + UNMOUNT_MEDIA = "mount.unmedia", + MOUNT_LIST = "mount.list", + + // Extension permissions + EXTENSION_MANAGE = "extension.manage", + EXTENSION_ATX = "extension.atx", + EXTENSION_DC = "extension.dc", + EXTENSION_SERIAL = "extension.serial", + EXTENSION_WOL = "extension.wol", + + // Settings permissions + SETTINGS_READ = "settings.read", + SETTINGS_WRITE = "settings.write", + SETTINGS_ACCESS = "settings.access", + + // System permissions + SYSTEM_REBOOT = "system.reboot", + SYSTEM_UPDATE = "system.update", + SYSTEM_NETWORK = "system.network", + + // Power/USB control permissions + POWER_CONTROL = "power.control", + USB_CONTROL = "usb.control", + + // Terminal/Serial permissions + TERMINAL_ACCESS = "terminal.access", + SERIAL_ACCESS = "serial.access", +} + +interface PermissionsResponse { + mode: string; + permissions: Record; +} + +export function usePermissions() { + const { currentMode } = useSessionStore(); + const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore(); + const [permissions, setPermissions] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const previousCanControl = useRef(false); + + // Function to poll permissions + const pollPermissions = useCallback((send: any) => { + if (!send) return; + + setIsLoading(true); + send("getPermissions", {}, (response: any) => { + if (!response.error && response.result) { + const result = response.result as PermissionsResponse; + setPermissions(result.permissions); + } + setIsLoading(false); + }); + }, []); + + // Handle connectionModeChanged events that require WebRTC reconnection + const handleRpcRequest = useCallback((request: any) => { + if (request.method === "connectionModeChanged") { + console.info("Connection mode changed, WebRTC reconnection required", request.params); + + // For session promotion that requires reconnection, refresh the page + // This ensures WebRTC connection is re-established with proper mode + if (request.params?.action === "reconnect_required" && request.params?.reason === "session_promotion") { + console.info("Session promoted, refreshing page to re-establish WebRTC connection"); + // Small delay to ensure all state updates are processed + setTimeout(() => { + window.location.reload(); + }, 500); + } + } + }, []); + + const { send } = useJsonRpc(handleRpcRequest); + + useEffect(() => { + pollPermissions(send); + }, [send, currentMode, pollPermissions]); + + // Monitor permission changes and re-initialize HID when gaining control + useEffect(() => { + const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT); + const hadControl = previousCanControl.current; + + // If we just gained control permissions, re-initialize HID + if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") { + console.info("Gained control permissions, re-initializing HID"); + + // Reset protocol version to force re-handshake + setRpcHidProtocolVersion(null); + + // Import handshake functionality dynamically + import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => { + // Send handshake after a small delay + setTimeout(() => { + if (rpcHidChannel?.readyState === "open") { + const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION); + try { + const data = handshakeMessage.marshal(); + rpcHidChannel.send(data as unknown as ArrayBuffer); + console.info("Sent HID handshake after permission change"); + } catch (e) { + console.error("Failed to send HID handshake", e); + } + } + }, 100); + }); + } + + previousCanControl.current = currentCanControl; + }, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); + + const hasPermission = (permission: Permission): boolean => { + return permissions[permission] === true; + }; + + const hasAnyPermission = (...perms: Permission[]): boolean => { + return perms.some(perm => hasPermission(perm)); + }; + + const hasAllPermissions = (...perms: Permission[]): boolean => { + return perms.every(perm => hasPermission(perm)); + }; + + // Session mode helpers + const isPrimary = () => currentMode === "primary"; + const isObserver = () => currentMode === "observer"; + const isPending = () => currentMode === "pending"; + + return { + permissions, + isLoading, + hasPermission, + hasAnyPermission, + hasAllPermissions, + isPrimary, + isObserver, + isPending, + }; +} \ No newline at end of file diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts new file mode 100644 index 00000000..393ff1c8 --- /dev/null +++ b/ui/src/hooks/useSessionEvents.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef } from "react"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; +import { sessionApi } from "@/api/sessionApi"; +import { notify } from "@/notifications"; + +interface SessionEventData { + sessions: any[]; + yourMode: string; +} + +interface ModeChangedData { + mode: string; +} + +export function useSessionEvents(sendFn: Function | null) { + const { + currentMode, + setSessions, + updateSessionMode, + setSessionError + } = useSessionStore(); + + const sendFnRef = useRef(sendFn); + sendFnRef.current = sendFn; + + // Handle session-related RPC events + const handleSessionEvent = (method: string, params: any) => { + switch (method) { + case "sessionsUpdated": + handleSessionsUpdated(params as SessionEventData); + break; + case "modeChanged": + handleModeChanged(params as ModeChangedData); + break; + case "hidReadyForPrimary": + handleHidReadyForPrimary(); + break; + case "otherSessionConnected": + handleOtherSessionConnected(); + break; + default: + break; + } + }; + + const handleSessionsUpdated = (data: SessionEventData) => { + if (data.sessions) { + setSessions(data.sessions); + } + + // CRITICAL: Only update mode, never show notifications from sessionsUpdated + // Notifications are exclusively handled by handleModeChanged to prevent duplicates + if (data.yourMode && data.yourMode !== currentMode) { + updateSessionMode(data.yourMode as any); + } + }; + + // Debounce notifications to prevent rapid-fire duplicates + const lastNotificationRef = useRef<{mode: string, timestamp: number}>({mode: "", timestamp: 0}); + + const handleModeChanged = (data: ModeChangedData) => { + if (data.mode) { + // Get the most current mode from the store to avoid race conditions + const { currentMode: currentModeFromStore } = useSessionStore.getState(); + const previousMode = currentModeFromStore; + updateSessionMode(data.mode as any); + + // Clear requesting state when mode changes from queued + if (previousMode === "queued" && data.mode !== "queued") { + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + // HID re-initialization is now handled automatically by permission changes in usePermissions + + // CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events + const now = Date.now(); + const lastNotification = lastNotificationRef.current; + + // Only show notification if: + // 1. Mode actually changed, AND + // 2. Haven't shown the same notification in the last 2 seconds + const shouldNotify = previousMode !== data.mode && + (lastNotification.mode !== data.mode || now - lastNotification.timestamp > 2000); + + if (shouldNotify) { + if (data.mode === "primary") { + notify.success("Primary control granted"); + lastNotificationRef.current = {mode: "primary", timestamp: now}; + } else if (data.mode === "observer" && previousMode === "primary") { + notify.info("Primary control released"); + lastNotificationRef.current = {mode: "observer", timestamp: now}; + } + } + } + }; + + const handleHidReadyForPrimary = () => { + // Backend signals that HID system is ready for primary session re-initialization + const { rpcHidChannel } = useRTCStore.getState(); + if (rpcHidChannel?.readyState === "open") { + // Trigger HID re-handshake + rpcHidChannel.dispatchEvent(new Event("open")); + } + }; + + const handleOtherSessionConnected = () => { + // Another session is trying to connect + notify.warning("Another session is connecting", { + duration: 5000 + }); + }; + + // Fetch initial sessions when component mounts + useEffect(() => { + if (!sendFnRef.current) return; + + const fetchSessions = async () => { + try { + const sessions = await sessionApi.getSessions(sendFnRef.current!); + setSessions(sessions); + } catch (error) { + console.error("Failed to fetch sessions:", error); + setSessionError("Failed to fetch session information"); + } + }; + + fetchSessions(); + }, [setSessions, setSessionError]); + + // Set up periodic session refresh + useEffect(() => { + if (!sendFnRef.current) return; + + const intervalId = setInterval(async () => { + if (!sendFnRef.current) return; + + try { + const sessions = await sessionApi.getSessions(sendFnRef.current); + setSessions(sessions); + } catch (error) { + // Silently fail on refresh errors + } + }, 30000); // Refresh every 30 seconds + + return () => clearInterval(intervalId); + }, [setSessions]); + + return { + handleSessionEvent + }; +} \ No newline at end of file diff --git a/ui/src/hooks/useSessionManagement.ts b/ui/src/hooks/useSessionManagement.ts new file mode 100644 index 00000000..42078412 --- /dev/null +++ b/ui/src/hooks/useSessionManagement.ts @@ -0,0 +1,177 @@ +import { useEffect, useCallback, useState } from "react"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useSessionEvents } from "@/hooks/useSessionEvents"; +import { useSettingsStore } from "@/hooks/stores"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +interface SessionResponse { + sessionId?: string; + mode?: string; +} + +interface PrimaryControlRequest { + requestId: string; + identity: string; + source: string; + nickname?: string; +} + +interface NewSessionRequest { + sessionId: string; + source: "local" | "cloud"; + identity?: string; + nickname?: string; +} + +export function useSessionManagement(sendFn: Function | null) { + const { + setCurrentSession, + clearSession + } = useSessionStore(); + + const { hasPermission } = usePermissions(); + + const { requireSessionApproval } = useSettingsStore(); + const { handleSessionEvent } = useSessionEvents(sendFn); + const [primaryControlRequest, setPrimaryControlRequest] = useState(null); + const [newSessionRequest, setNewSessionRequest] = useState(null); + + // Handle session info from WebRTC answer + const handleSessionResponse = useCallback((response: SessionResponse) => { + if (response.sessionId && response.mode) { + setCurrentSession(response.sessionId, response.mode as any); + } + }, [setCurrentSession]); + + // Handle approval of primary control request + const handleApprovePrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: any) => { + if (response.error) { + console.error("Failed to approve primary request:", response.error); + reject(new Error(response.error.message || "Failed to approve")); + } else { + setPrimaryControlRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + // Handle denial of primary control request + const handleDenyPrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: any) => { + if (response.error) { + console.error("Failed to deny primary request:", response.error); + reject(new Error(response.error.message || "Failed to deny")); + } else { + setPrimaryControlRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + // Handle approval of new session + const handleApproveNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: any) => { + if (response.error) { + console.error("Failed to approve new session:", response.error); + reject(new Error(response.error.message || "Failed to approve")); + } else { + setNewSessionRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + // Handle denial of new session + const handleDenyNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: any) => { + if (response.error) { + console.error("Failed to deny new session:", response.error); + reject(new Error(response.error.message || "Failed to deny")); + } else { + setNewSessionRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + // Handle RPC events + const handleRpcEvent = useCallback((method: string, params: any) => { + // Pass session events to the session event handler + if (method === "sessionsUpdated" || + method === "modeChanged" || + method === "otherSessionConnected") { + handleSessionEvent(method, params); + } + + // Handle new session approval request (only if approval is required and user has permission) + if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) { + setNewSessionRequest(params); + } + + // Handle primary control request + if (method === "primaryControlRequested") { + setPrimaryControlRequest(params); + } + + // Handle approval/denial responses + if (method === "primaryControlApproved") { + // Clear requesting state in store + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + if (method === "primaryControlDenied") { + // Clear requesting state and show error + const { setRequestingPrimary, setSessionError } = useSessionStore.getState(); + setRequestingPrimary(false); + setSessionError("Your primary control request was denied"); + } + + // Handle session access denial (when your new session is denied) + if (method === "sessionAccessDenied") { + const { clearSession, setSessionError } = useSessionStore.getState(); + setSessionError(params.message || "Session access was denied by the primary session"); + // Clear session data as we're being disconnected + setTimeout(() => { + clearSession(); + }, 3000); // Give user time to see the error + } + }, [handleSessionEvent]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearSession(); + }; + }, [clearSession]); + + return { + handleSessionResponse, + handleRpcEvent, + primaryControlRequest, + handleApprovePrimaryRequest, + handleDenyPrimaryRequest, + closePrimaryControlRequest: () => setPrimaryControlRequest(null), + newSessionRequest, + handleApproveNewSession, + handleDenyNewSession, + closeNewSessionRequest: () => setNewSessionRequest(null) + }; +} \ No newline at end of file diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 79ca6717..f05d92f7 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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: , + }, ], }, ], @@ -344,6 +349,10 @@ if (isOnDevice) { }, ], }, + { + path: "sessions", + element: , + }, ], }, ], diff --git a/ui/src/notifications.tsx b/ui/src/notifications.tsx index 5158d8d3..a10e63a3 100644 --- a/ui/src/notifications.tsx +++ b/ui/src/notifications.tsx @@ -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 => ( + } + message={message} + t={t} + /> + ), + { duration: 2000, ...options }, + ); + }, + + warning: (message: string, options?: NotificationOptions) => { + return toast.custom( + t => ( + } + 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); diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index f30bfef1..18a680a4 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -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() { )} +
{ setDisplayRotation(rotation); @@ -58,17 +60,39 @@ export default function SettingsHardwareRoute() { }); }; + // Check permissions before fetching settings data useEffect(() => { - send("getBacklightSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - return notifications.error( - `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, - ); - } - const result = resp.result as BacklightSettings; - setBacklightSettings(result); - }); - }, [send, setBacklightSettings]); + // Only fetch settings if user has permission + if (!isLoading && permissions[Permission.SETTINGS_READ] === true) { + send("getBacklightSettings", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + return notifications.error( + `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, + ); + } + const result = resp.result as BacklightSettings; + setBacklightSettings(result); + }); + } + }, [send, setBacklightSettings, isLoading, permissions]); + + // Return early if permissions are loading + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + // Return early if no permission + if (!hasPermission(Permission.SETTINGS_READ)) { + return ( +
+
Access Denied: You do not have permission to view these settings.
+
+ ); + } return (
diff --git a/ui/src/routes/devices.$id.settings.multi-session.tsx b/ui/src/routes/devices.$id.settings.multi-session.tsx new file mode 100644 index 00000000..fab886e1 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from "react"; +import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; +import { useSettingsStore } from "@/hooks/stores"; +import { notify } from "@/notifications"; +import Card from "@/components/Card"; +import Checkbox from "@/components/Checkbox"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { SettingsItem } from "@/components/SettingsItem"; +import { + UserGroupIcon, +} from "@heroicons/react/16/solid"; + +export default function SessionsSettings() { + const { send } = useJsonRpc(); + const { hasPermission } = usePermissions(); + const canModifySettings = hasPermission(Permission.SETTINGS_WRITE); + + const { + requireSessionNickname, + setRequireSessionNickname, + requireSessionApproval, + setRequireSessionApproval + } = useSettingsStore(); + + const [reconnectGrace, setReconnectGrace] = useState(10); + const [primaryTimeout, setPrimaryTimeout] = useState(300); + const [privateKeystrokes, setPrivateKeystrokes] = useState(false); + + useEffect(() => { + send("getSessionSettings", {}, (response: JsonRpcResponse) => { + if ("error" in response) { + console.error("Failed to get session settings:", response.error); + } else { + const settings = response.result as { + requireApproval: boolean; + requireNickname: boolean; + reconnectGrace?: number; + primaryTimeout?: number; + privateKeystrokes?: boolean + }; + setRequireSessionApproval(settings.requireApproval); + setRequireSessionNickname(settings.requireNickname); + if (settings.reconnectGrace !== undefined) { + setReconnectGrace(settings.reconnectGrace); + } + if (settings.primaryTimeout !== undefined) { + setPrimaryTimeout(settings.primaryTimeout); + } + if (settings.privateKeystrokes !== undefined) { + setPrivateKeystrokes(settings.privateKeystrokes); + } + } + }); + }, [send, setRequireSessionApproval, setRequireSessionNickname]); + + const updateSessionSettings = (updates: Partial<{ + requireApproval: boolean; + requireNickname: boolean; + reconnectGrace: number; + primaryTimeout: number; + privateKeystrokes: boolean; + }>) => { + if (!canModifySettings) { + notify.error("Only the primary session can change this setting"); + return; + } + + send("setSessionSettings", { + settings: { + requireApproval: requireSessionApproval, + requireNickname: requireSessionNickname, + reconnectGrace: reconnectGrace, + primaryTimeout: primaryTimeout, + privateKeystrokes: privateKeystrokes, + ...updates + } + }, (response: JsonRpcResponse) => { + if ("error" in response) { + console.error("Failed to update session settings:", response.error); + notify.error("Failed to update session settings"); + } + }); + }; + + return ( +
+ + + {!canModifySettings && ( + +
+ Note: Only the primary session can modify these settings. + Request primary control to change settings. +
+
+ )} + + +
+
+ +

+ Access Control +

+
+ + + { + const newValue = e.target.checked; + setRequireSessionApproval(newValue); + updateSessionSettings({ requireApproval: newValue }); + notify.success( + newValue + ? "New sessions will require approval" + : "New sessions will be automatically approved" + ); + }} + /> + + + + { + const newValue = e.target.checked; + setRequireSessionNickname(newValue); + updateSessionSettings({ requireNickname: newValue }); + notify.success( + newValue + ? "Session nicknames are now required" + : "Session nicknames are now optional" + ); + }} + /> + + + +
+ { + 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" + /> + seconds +
+
+ + +
+ { + 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" + /> + seconds +
+
+ + + { + 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" + ); + }} + /> + +
+
+ + +
+
+

+ How Multi-Session Access Works +

+
+
+ Primary: + Full control over the KVM device including keyboard, mouse, and settings +
+
+ Observer: + View-only access to monitor activity without control capabilities +
+
+ Pending: + Awaiting approval from the primary session (when approval is required) +
+
+
+ Use the Sessions panel in the top navigation bar to view and manage active sessions. +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 338beb97..23bc4c2f 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -12,19 +12,33 @@ import { LuPalette, LuCommand, LuNetwork, + LuUsers, } from "react-icons/lu"; import { useResizeObserver } from "usehooks-ts"; +import { useNavigate } from "react-router"; import { cx } from "@/cva.config"; import Card from "@components/Card"; import { LinkButton } from "@components/Button"; import { FeatureFlag } from "@components/FeatureFlag"; import { useUiStore } from "@/hooks/stores"; +import { useSessionStore } from "@/stores/sessionStore"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { const location = useLocation(); + const navigate = useNavigate(); const { setDisableVideoFocusTrap } = useUiStore(); + const { currentMode } = useSessionStore(); + const { hasPermission, isLoading, permissions } = usePermissions(); + + useEffect(() => { + if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) { + navigate("/devices/local", { replace: true }); + } + }, [permissions, isLoading, currentMode, navigate]); + const scrollContainerRef = useRef(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 ( +
+
Checking permissions...
+
+ ); + } + + // Don't render settings content if user doesn't have permission + if (!hasPermission(Permission.SETTINGS_ACCESS)) { + return null; + } + return (
@@ -223,6 +252,17 @@ export default function SettingsRoute() {
+
+ (isActive ? "active" : "")} + > +
+ +

Multi-Session Access

+
+
+
import('@/components/sidebar/connectionStats')); const Terminal = lazy(() => import('@components/Terminal')); @@ -50,6 +56,9 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; import { DeviceStatus } from "@routes/welcome-local"; import { useVersion } from "@/hooks/useVersion"; +import { useSessionManagement } from "@/hooks/useSessionManagement"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import { sessionApi } from "@/api/sessionApi"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; @@ -122,7 +131,7 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap } = useUiStore(); const [ queryParams, setQueryParams ] = useSearchParams(); const { @@ -141,14 +150,20 @@ export default function KvmIdRoute() { const location = useLocation(); const isLegacySignalingEnabled = useRef(false); const [connectionFailed, setConnectionFailed] = useState(false); + const [showNicknameModal, setShowNicknameModal] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); const navigate = useNavigate(); const { otaState, setOtaState, setModalView } = useUpdateStore(); + const { currentSessionId, currentMode, setCurrentSession } = useSessionStore(); + const { nickname, setNickname } = useSharedSessionStore(); + const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore(); + const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null); + const { hasPermission } = usePermissions(); const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const cleanupAndStopReconnecting = useCallback( function cleanupAndStopReconnecting() { - console.log("Closing peer connection"); setConnectionFailed(true); if (peerConnection) { @@ -186,7 +201,6 @@ export default function KvmIdRoute() { try { await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription)); - console.log("[setRemoteSessionDescription] Remote description set successfully"); setLoadingMessage("Establishing secure connection..."); } catch (error) { console.error( @@ -204,7 +218,6 @@ export default function KvmIdRoute() { // When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects if (pc.sctp?.state === "connected") { - console.log("[setRemoteSessionDescription] Remote description set"); clearInterval(checkInterval); setLoadingMessage("Connection established"); } else if (attempts >= 10) { @@ -218,10 +231,6 @@ export default function KvmIdRoute() { cleanupAndStopReconnecting(); clearInterval(checkInterval); } else { - console.log("[setRemoteSessionDescription] Waiting for connection, state:", { - connectionState: pc.connectionState, - iceConnectionState: pc.iceConnectionState, - }); } }, 1000); }, @@ -244,18 +253,15 @@ export default function KvmIdRoute() { reconnectAttempts: 15, reconnectInterval: 1000, onReconnectStop: () => { - console.debug("Reconnect stopped"); cleanupAndStopReconnecting(); }, - shouldReconnect(event) { - console.debug("[Websocket] shouldReconnect", event); + shouldReconnect(_event) { // TODO: Why true? return true; }, - onClose(event) { - console.debug("[Websocket] onClose", event); + onClose(_event) { // We don't want to close everything down, we wait for the reconnect to stop instead }, @@ -264,7 +270,6 @@ export default function KvmIdRoute() { // We don't want to close everything down, we wait for the reconnect to stop instead }, onOpen() { - console.debug("[Websocket] onOpen"); }, onMessage: message => { @@ -285,27 +290,49 @@ export default function KvmIdRoute() { const parsedMessage = JSON.parse(message.data); if (parsedMessage.type === "device-metadata") { - const { deviceVersion } = parsedMessage.data; - console.debug("[Websocket] Received device-metadata message"); - console.debug("[Websocket] Device version", deviceVersion); + const { deviceVersion, sessionSettings } = parsedMessage.data; + + // Store session settings if provided + if (sessionSettings) { + setGlobalSessionSettings({ + requireNickname: sessionSettings.requireNickname || false, + requireApproval: sessionSettings.requireApproval || false + }); + // Also update the settings store for approval handling + setRequireSessionApproval(sessionSettings.requireApproval || false); + setRequireSessionNickname(sessionSettings.requireNickname || false); + } + // If the device version is not set, we can assume the device is using the legacy signaling if (!deviceVersion) { - console.log("[Websocket] Device is using legacy signaling"); // Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling // which does everything over HTTP(at least from the perspective of the client) isLegacySignalingEnabled.current = true; getWebSocket()?.close(); } else { - console.log("[Websocket] Device is using new signaling"); isLegacySignalingEnabled.current = false; } + + // Always setup peer connection first to establish RPC channel for nickname generation setupPeerConnection(); + + // Check if nickname is required and not set - modal will be shown after RPC channel is ready + const requiresNickname = sessionSettings?.requireNickname || false; + + if (requiresNickname && !nickname) { + // Store that we need to show the nickname modal once RPC is ready + // The useEffect in NicknameModal will handle waiting for RPC channel readiness + setShowNicknameModal(true); + setDisableVideoFocusTrap(true); + } } - if (!peerConnection) return; + if (!peerConnection) { + console.warn("[Websocket] Ignoring message because peerConnection is not ready:", parsedMessage.type); + return; + } if (parsedMessage.type === "answer") { - console.debug("[Websocket] Received answer"); const readyForOffer = // If we're making an offer, we don't want to accept an answer !makingOffer && @@ -319,14 +346,41 @@ export default function KvmIdRoute() { // Set so we don't accept an answer while we're setting the remote description isSettingRemoteAnswerPending.current = parsedMessage.type === "answer"; - console.debug( - "[Websocket] Setting remote answer pending", - isSettingRemoteAnswerPending.current, - ); const sd = atob(parsedMessage.data); const remoteSessionDescription = JSON.parse(sd); + if (parsedMessage.sessionId && parsedMessage.mode) { + handleSessionResponse({ + sessionId: parsedMessage.sessionId, + mode: parsedMessage.mode + }); + + // Store sessionId via zustand (persists to sessionStorage for per-tab isolation) + setCurrentSession(parsedMessage.sessionId, parsedMessage.mode); + if (parsedMessage.requireNickname !== undefined && parsedMessage.requireApproval !== undefined) { + setGlobalSessionSettings({ + requireNickname: parsedMessage.requireNickname, + requireApproval: parsedMessage.requireApproval + }); + // Also update the settings store for approval handling + setRequireSessionApproval(parsedMessage.requireApproval); + setRequireSessionNickname(parsedMessage.requireNickname); + } + + // Show nickname modal if: + // 1. Nickname is required by backend settings + // 2. We don't already have a nickname + // This happens even for pending sessions so the nickname is included in approval + const hasNickname = parsedMessage.nickname && parsedMessage.nickname.length > 0; + const requiresNickname = parsedMessage.requireNickname || globalSessionSettings?.requireNickname; + + if (requiresNickname && !hasNickname) { + setShowNicknameModal(true); + setDisableVideoFocusTrap(true); + } + } + setRemoteSessionDescription( peerConnection, new RTCSessionDescription(remoteSessionDescription), @@ -335,9 +389,11 @@ export default function KvmIdRoute() { // Reset the remote answer pending flag isSettingRemoteAnswerPending.current = false; } else if (parsedMessage.type === "new-ice-candidate") { - console.debug("[Websocket] Received new-ice-candidate"); const candidate = parsedMessage.data; - peerConnection.addIceCandidate(candidate); + // Always try to add the ICE candidate - the browser will queue it internally if needed + peerConnection.addIceCandidate(candidate).catch(error => { + console.warn("[Websocket] Failed to add ICE candidate:", error); + }); } }, }, @@ -350,9 +406,16 @@ export default function KvmIdRoute() { (type: string, data: unknown) => { // Second argument tells the library not to queue the message, and send it once the connection is established again. // We have event handlers that handle the connection set up, so we don't need to queue the message. - sendMessage(JSON.stringify({ type, data }), false); + const message = JSON.stringify({ type, data }); + const ws = getWebSocket(); + if (ws?.readyState === WebSocket.OPEN) { + sendMessage(message, false); + } else { + console.warn(`[WebSocket] WebSocket not open, queuing message:`, message); + sendMessage(message, true); // Queue the message + } }, - [sendMessage], + [sendMessage, getWebSocket], ); const legacyHTTPSignaling = useCallback( @@ -363,12 +426,12 @@ export default function KvmIdRoute() { // In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled const sessionUrl = `${CLOUD_API}/webrtc/session`; - console.log("Trying to get remote session description"); setLoadingMessage( `Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`, ); const res = await api.POST(sessionUrl, { sd, + userAgent: navigator.userAgent, // When on device, we don't need to specify the device id, as it's already known ...(isOnDevice ? {} : { id: params.id }), }); @@ -381,7 +444,6 @@ export default function KvmIdRoute() { return; } - console.debug("Successfully got Remote Session Description. Setting."); setLoadingMessage("Setting remote session description..."); const decodedSd = atob(json.sd); @@ -392,13 +454,11 @@ export default function KvmIdRoute() { ); const setupPeerConnection = useCallback(async () => { - console.debug("[setupPeerConnection] Setting up peer connection"); setConnectionFailed(false); setLoadingMessage("Connecting to device..."); let pc: RTCPeerConnection; try { - console.debug("[setupPeerConnection] Creating peer connection"); setLoadingMessage("Creating peer connection..."); pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud @@ -408,7 +468,6 @@ export default function KvmIdRoute() { }); setPeerConnectionState(pc.connectionState); - console.debug("[setupPeerConnection] Peer connection created", pc); setLoadingMessage("Setting up connection to device..."); } catch (e) { console.error(`[setupPeerConnection] Error creating peer connection: ${e}`); @@ -420,13 +479,11 @@ export default function KvmIdRoute() { // Set up event listeners and data channels pc.onconnectionstatechange = () => { - console.debug("[setupPeerConnection] Connection state changed", pc.connectionState); setPeerConnectionState(pc.connectionState); }; pc.onnegotiationneeded = async () => { try { - console.debug("[setupPeerConnection] Creating offer"); makingOffer.current = true; const offer = await pc.createOffer(); @@ -434,9 +491,19 @@ export default function KvmIdRoute() { const sd = btoa(JSON.stringify(pc.localDescription)); const isNewSignalingEnabled = isLegacySignalingEnabled.current === false; if (isNewSignalingEnabled) { - sendWebRTCSignal("offer", { sd: sd }); - } else { - console.log("Legacy signaling. Waiting for ICE Gathering to complete..."); + // Get nickname and sessionId from zustand stores + // sessionId is per-tab (sessionStorage), nickname is shared (localStorage) + const { currentSessionId: storeSessionId } = useSessionStore.getState(); + const { nickname: storeNickname } = useSharedSessionStore.getState(); + + sendWebRTCSignal("offer", { + sd: sd, + sessionId: storeSessionId || undefined, + userAgent: navigator.userAgent, + sessionSettings: { + nickname: storeNickname || undefined + } + }); } } catch (e) { console.error( @@ -450,15 +517,18 @@ export default function KvmIdRoute() { }; pc.onicecandidate = ({ candidate }) => { - if (!candidate) return; - if (candidate.candidate === "") return; + if (!candidate) { + return; + } + if (candidate.candidate === "") { + return; + } sendWebRTCSignal("new-ice-candidate", candidate); }; pc.onicegatheringstatechange = event => { const pc = event.currentTarget as RTCPeerConnection; if (pc.iceGatheringState === "complete") { - console.debug("ICE Gathering completed"); setLoadingMessage("ICE Gathering completed"); if (isLegacySignalingEnabled.current) { @@ -466,7 +536,6 @@ export default function KvmIdRoute() { legacyHTTPSignaling(pc); } } else if (pc.iceGatheringState === "gathering") { - console.debug("ICE Gathering Started"); setLoadingMessage("Gathering ICE candidates..."); } }; @@ -480,6 +549,44 @@ export default function KvmIdRoute() { const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); + + // Fetch global session settings + const fetchSettings = () => { + // Only fetch settings if user has permission to read settings + if (!hasPermission(Permission.SETTINGS_READ)) { + return; + } + + const id = Math.random().toString(36).substring(2); + const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id }); + + const handler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data); + if (response.id === id) { + rpcDataChannel.removeEventListener("message", handler); + if (response.result) { + setGlobalSessionSettings(response.result); + // Also update the settings store for approval handling + setRequireSessionApproval(response.result.requireApproval); + setRequireSessionNickname(response.result.requireNickname); + } + } + } catch (error) { + // Ignore parse errors + } + }; + + rpcDataChannel.addEventListener("message", handler); + rpcDataChannel.send(message); + + // Clean up after timeout + setTimeout(() => { + rpcDataChannel.removeEventListener("message", handler); + }, 5000); + }; + + fetchSettings(); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); @@ -609,42 +716,54 @@ export default function KvmIdRoute() { const { navigateTo } = useDeviceUiNavigation(); function onJsonRpcRequest(resp: JsonRpcRequest) { - if (resp.method === "otherSessionConnected") { - navigateTo("/other-session"); + // Handle session-related events + if (resp.method === "sessionsUpdated" || + resp.method === "modeChanged" || + resp.method === "otherSessionConnected" || + resp.method === "primaryControlRequested" || + resp.method === "primaryControlApproved" || + resp.method === "primaryControlDenied" || + resp.method === "newSessionPending" || + resp.method === "sessionAccessDenied") { + handleRpcEvent(resp.method, resp.params); + + // Show access denied overlay if our session was denied + if (resp.method === "sessionAccessDenied") { + setAccessDenied(true); + } + + // Keep legacy behavior for otherSessionConnected + if (resp.method === "otherSessionConnected") { + navigateTo("/other-session"); + } } if (resp.method === "usbState") { const usbState = resp.params as unknown as USBStates; - console.debug("Setting USB state", usbState); setUsbState(usbState); } if (resp.method === "videoInputState") { const hdmiState = resp.params as Parameters[0]; - console.debug("Setting HDMI state", hdmiState); setHdmiState(hdmiState); } if (resp.method === "networkState") { - console.debug("Setting network state", resp.params); setNetworkState(resp.params as NetworkState); } if (resp.method === "keyboardLedState") { const ledState = resp.params as KeyboardLedState; - console.debug("Setting keyboard led state", ledState); setKeyboardLedState(ledState); } if (resp.method === "keysDownState") { const downState = resp.params as KeysDownState; - console.debug("Setting key down state:", downState); setKeysDownState(downState); } if (resp.method === "otaState") { const otaState = resp.params as OtaState; - console.debug("Setting OTA state", otaState); setOtaState(otaState); if (otaState.updating === true) { @@ -670,13 +789,24 @@ export default function KvmIdRoute() { const { send } = useJsonRpc(onJsonRpcRequest); + const { + handleSessionResponse, + handleRpcEvent, + primaryControlRequest, + handleApprovePrimaryRequest, + handleDenyPrimaryRequest, + closePrimaryControlRequest, + newSessionRequest, + handleApproveNewSession, + handleDenyNewSession, + closeNewSessionRequest + } = useSessionManagement(send); + useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - console.log("Requesting video state"); send("getVideoState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; const hdmiState = resp.result as Parameters[0]; - console.debug("Setting HDMI state", hdmiState); setHdmiState(hdmiState); }); }, [rpcDataChannel?.readyState, send, setHdmiState]); @@ -687,7 +817,6 @@ export default function KvmIdRoute() { useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; if (!needLedState) return; - console.log("Requesting keyboard led state"); send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -695,7 +824,6 @@ export default function KvmIdRoute() { return; } else { const ledState = resp.result as KeyboardLedState; - console.debug("Keyboard led state: ", ledState); setKeyboardLedState(ledState); } setNeedLedState(false); @@ -708,7 +836,6 @@ export default function KvmIdRoute() { useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; if (!needKeyDownState) return; - console.log("Requesting keys down state"); send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -722,7 +849,6 @@ export default function KvmIdRoute() { } } else { const downState = resp.result as KeysDownState; - console.debug("Keyboard key down state", downState); setKeysDownState(downState); } setNeedKeyDownState(false); @@ -840,16 +966,29 @@ export default function KvmIdRoute() { kvmName={deviceName ?? "JetKVM Device"} /> +
- -
-
- {!!ConnectionStatusElement && ConnectionStatusElement} + {/* Only show video feed if nickname is set (when required) and not pending approval */} + {(!showNicknameModal && currentMode !== "pending") ? ( + <> + +
+
+ {!!ConnectionStatusElement && ConnectionStatusElement} +
+
+ + ) : ( +
+
+ {showNicknameModal &&

Please set your nickname to continue

} + {currentMode === "pending" &&

Waiting for session approval...

} +
-
+ )}
@@ -870,6 +1009,27 @@ export default function KvmIdRoute() { {/* The 'used by other session' modal needs to have access to the connectWebRTC function */} + + { + 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); + }} + />
{kvmTerminal && ( @@ -879,6 +1039,60 @@ export default function KvmIdRoute() { {serialConsole && ( )} + + {/* Unified Session Request Dialog */} + {(primaryControlRequest || newSessionRequest) && ( + + )} + + { + setAccessDenied(false); + // Attempt to reconnect + window.location.reload(); + }} + /> + + ); } diff --git a/ui/src/stores/sessionStore.ts b/ui/src/stores/sessionStore.ts new file mode 100644 index 00000000..3e7a57b9 --- /dev/null +++ b/ui/src/stores/sessionStore.ts @@ -0,0 +1,160 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export type SessionMode = "primary" | "observer" | "queued" | "pending"; + +export interface SessionInfo { + id: string; + mode: SessionMode; + source: "local" | "cloud"; + identity?: string; + nickname?: string; + createdAt: string; + lastActive: string; +} + +export interface SessionState { + // Current session info + currentSessionId: string | null; + currentMode: SessionMode | null; + + // All active sessions + sessions: SessionInfo[]; + + // UI state + isRequestingPrimary: boolean; + sessionError: string | null; + + // Actions + setCurrentSession: (id: string, mode: SessionMode) => void; + setSessions: (sessions: SessionInfo[]) => void; + setRequestingPrimary: (requesting: boolean) => void; + setSessionError: (error: string | null) => void; + updateSessionMode: (mode: SessionMode) => void; + clearSession: () => void; + + // Computed getters + isPrimary: () => boolean; + isObserver: () => boolean; + isQueued: () => boolean; + isPending: () => boolean; + canRequestPrimary: () => boolean; + getPrimarySession: () => SessionInfo | undefined; + getQueuePosition: () => number; +} + +export const useSessionStore = create()( + persist( + (set, get) => ({ + // Initial state + currentSessionId: null, + currentMode: null, + sessions: [], + isRequestingPrimary: false, + sessionError: null, + + // Actions + setCurrentSession: (id: string, mode: SessionMode) => { + set({ + currentSessionId: id, + currentMode: mode, + sessionError: null + }); + }, + + setSessions: (sessions: SessionInfo[]) => { + set({ sessions }); + }, + + setRequestingPrimary: (requesting: boolean) => { + set({ isRequestingPrimary: requesting }); + }, + + setSessionError: (error: string | null) => { + set({ sessionError: error }); + }, + + updateSessionMode: (mode: SessionMode) => { + set({ currentMode: mode }); + }, + + clearSession: () => { + set({ + currentSessionId: null, + currentMode: null, + sessions: [], + sessionError: null, + isRequestingPrimary: false + }); + }, + + // Computed getters + isPrimary: () => { + return get().currentMode === "primary"; + }, + + isObserver: () => { + return get().currentMode === "observer"; + }, + + isQueued: () => { + return get().currentMode === "queued"; + }, + + isPending: () => { + return get().currentMode === "pending"; + }, + + canRequestPrimary: () => { + const state = get(); + return state.currentMode === "observer" && + !state.isRequestingPrimary && + state.sessions.some(s => s.mode === "primary"); + }, + + getPrimarySession: () => { + return get().sessions.find(s => s.mode === "primary"); + }, + + getQueuePosition: () => { + const state = get(); + if (state.currentMode !== "queued") return -1; + + const queuedSessions = state.sessions + .filter(s => s.mode === "queued") + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + return queuedSessions.findIndex(s => s.id === state.currentSessionId) + 1; + } + }), + { + name: 'session', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ + currentSessionId: state.currentSessionId, + }), + } + ) +); + +// Shared session store - separate with localStorage (shared across tabs) +// Used for user preferences that should be consistent across all tabs +export interface SharedSessionState { + nickname: string | null; + setNickname: (nickname: string | null) => void; + clearNickname: () => void; +} + +export const useSharedSessionStore = create()( + persist( + (set) => ({ + nickname: null, + setNickname: (nickname: string | null) => set({ nickname }), + clearNickname: () => set({ nickname: null }), + }), + { + name: 'sharedSession', + storage: createJSONStorage(() => localStorage), + } + ) +); \ No newline at end of file diff --git a/ui/src/utils/nicknameGenerator.ts b/ui/src/utils/nicknameGenerator.ts new file mode 100644 index 00000000..3ce270fa --- /dev/null +++ b/ui/src/utils/nicknameGenerator.ts @@ -0,0 +1,33 @@ +// Nickname generation using backend API for consistency + +// Main function that uses backend generation +export async function generateNickname(sendFn?: Function): Promise { + // Require backend function - no fallback + if (!sendFn) { + throw new Error('Backend connection required for nickname generation'); + } + + return new Promise((resolve, reject) => { + try { + const result = sendFn('generateNickname', { userAgent: navigator.userAgent }, (response: any) => { + if (response && !response.error && response.result?.nickname) { + resolve(response.result.nickname); + } else { + reject(new Error('Failed to generate nickname from backend')); + } + }); + + // If sendFn returns undefined (RPC channel not ready), reject immediately + if (result === undefined) { + reject(new Error('RPC connection not ready yet')); + } + } catch (error) { + reject(error); + } + }); +} + +// Synchronous version removed - backend generation is always async +export function generateNicknameSync(): string { + throw new Error('Synchronous nickname generation not supported - use backend generateNickname()'); +} \ No newline at end of file diff --git a/usb.go b/usb.go index af57692f..87f54966 100644 --- a/usb.go +++ b/usb.go @@ -27,20 +27,43 @@ func initUsbGadget() { }() gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) { - if currentSession != nil { - currentSession.reportHidRPCKeyboardLedState(state) + // Check if keystrokes should be private + if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes { + // Report to primary session only + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.reportHidRPCKeyboardLedState(state) + } + } else { + // Report to all authorized sessions (primary and observers, but not pending) + sessionManager.ForEachSession(func(s *Session) { + if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver { + s.reportHidRPCKeyboardLedState(state) + } + }) } }) gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) { - if currentSession != nil { - currentSession.enqueueKeysDownState(state) + // Check if keystrokes should be private + if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes { + // Report to primary session only + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.enqueueKeysDownState(state) + } + } else { + // Report to all authorized sessions (primary and observers, but not pending) + sessionManager.ForEachSession(func(s *Session) { + if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver { + s.enqueueKeysDownState(state) + } + }) } }) gadget.SetOnKeepAliveReset(func() { - if currentSession != nil { - currentSession.resetKeepAliveTime() + // Reset keep-alive for primary session + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.resetKeepAliveTime() } }) @@ -50,26 +73,82 @@ func initUsbGadget() { } } -func rpcKeyboardReport(modifier byte, keys []byte) error { +func (s *Session) rpcKeyboardReport(modifier byte, keys []byte) error { + if s == nil || !s.HasPermission(PermissionKeyboardInput) { + return ErrPermissionDeniedKeyboard + } + sessionManager.UpdateLastActive(s.ID) return gadget.KeyboardReport(modifier, keys) } -func rpcKeypressReport(key byte, press bool) error { +func (s *Session) rpcKeypressReport(key byte, press bool) error { + if s == nil || !s.HasPermission(PermissionKeyboardInput) { + return ErrPermissionDeniedKeyboard + } + sessionManager.UpdateLastActive(s.ID) return gadget.KeypressReport(key, press) } -func rpcAbsMouseReport(x int, y int, buttons uint8) error { +func (s *Session) rpcAbsMouseReport(x 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) }() } diff --git a/video.go b/video.go index 3460440b..76eca8e4 100644 --- a/video.go +++ b/video.go @@ -8,7 +8,7 @@ var lastVideoState native.VideoState func triggerVideoStateUpdate() { go func() { - writeJSONRPCEvent("videoInputState", lastVideoState, currentSession) + broadcastJSONRPCEvent("videoInputState", lastVideoState) }() nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated") diff --git a/web.go b/web.go index 45253579..55a6d992 100644 --- a/web.go +++ b/web.go @@ -35,9 +35,21 @@ var staticFiles embed.FS type WebRTCSessionRequest struct { Sd string `json:"sd"` + SessionId string `json:"sessionId,omitempty"` OidcGoogle string `json:"OidcGoogle,omitempty"` IP string `json:"ip,omitempty"` ICEServers []string `json:"iceServers,omitempty"` + UserAgent string `json:"userAgent,omitempty"` // Browser user agent for nickname generation + SessionSettings *SessionSettings `json:"sessionSettings,omitempty"` +} + +type SessionSettings struct { + RequireApproval bool `json:"requireApproval"` + RequireNickname bool `json:"requireNickname"` + ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection + PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session + Nickname string `json:"nickname,omitempty"` + PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events } type SetPasswordRequest struct { @@ -158,32 +170,16 @@ func setupRouter() *gin.Engine { protected := r.Group("/") protected.Use(protectedMiddleware()) { - /* - * Legacy WebRTC session endpoint - * - * This endpoint is maintained for backward compatibility when users upgrade from a version - * using the legacy HTTP-based signaling method to the new WebSocket-based signaling method. - * - * During the upgrade process, when the "Rebooting device after update..." message appears, - * the browser still runs the previous JavaScript code which polls this endpoint to establish - * a new WebRTC session. Once the session is established, the page will automatically reload - * with the updated code. - * - * Without this endpoint, the stale JavaScript would fail to establish a connection, - * causing users to see the "Rebooting device after update..." message indefinitely - * until they manually refresh the page, leading to a confusing user experience. - */ - protected.POST("/webrtc/session", handleWebRTCSession) protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal) protected.POST("/cloud/register", handleCloudRegister) protected.GET("/cloud/state", handleCloudState) protected.GET("/device", handleDevice) protected.POST("/auth/logout", handleLogout) - protected.POST("/auth/password-local", handleCreatePassword) - protected.PUT("/auth/password-local", handleUpdatePassword) - protected.DELETE("/auth/local-password", handleDeletePassword) - protected.POST("/storage/upload", handleUploadHttp) + protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword) + protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword) + protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword) + protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp) } // Catch-all route for SPA @@ -198,44 +194,6 @@ func setupRouter() *gin.Engine { return r } -// TODO: support multiple sessions? -var currentSession *Session - -func handleWebRTCSession(c *gin.Context) { - var req WebRTCSessionRequest - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - session, err := newSession(SessionConfig{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err}) - return - } - - sd, err := session.ExchangeOffer(req.Sd) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err}) - return - } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() - } - - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() - - currentSession = session - c.JSON(http.StatusOK, gin.H{"sd": sd}) -} - var ( pingMessage = []byte("ping") pongMessage = []byte("pong") @@ -244,7 +202,15 @@ var ( func handleLocalWebRTCSignal(c *gin.Context) { // get the source from the request source := c.ClientIP() - connectionID := uuid.New().String() + + // Try to get existing session ID from cookie for session persistence + sessionID, _ := c.Cookie("sessionId") + if sessionID == "" { + sessionID = uuid.New().String() + // Set session ID cookie with same expiry as auth token (7 days) + c.SetCookie("sessionId", sessionID, 7*24*60*60, "/", "", false, true) + } + connectionID := sessionID scopedLogger := websocketLogger.With(). Str("component", "websocket"). @@ -276,7 +242,17 @@ func handleLocalWebRTCSignal(c *gin.Context) { // Now use conn for websocket operations defer wsCon.Close(websocket.StatusNormalClosure, "") - err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}}) + // Include session settings in device metadata so client knows requirements upfront + sessionSettingsData := gin.H{ + "deviceVersion": builtAppVersion, + } + if currentSessionSettings != nil { + sessionSettingsData["sessionSettings"] = gin.H{ + "requireNickname": currentSessionSettings.RequireNickname, + "requireApproval": currentSessionSettings.RequireApproval, + } + } + err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": sessionSettingsData}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -412,14 +388,17 @@ func handleWebRTCSignalWsMessages( continue } + l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message") + if message.Type == "offer" { - l.Info().Msg("new session request received") + l.Info().Str("dataRaw", string(message.Data)).Msg("new session request received with raw data") var req WebRTCSessionRequest err = json.Unmarshal(message.Data, &req) if err != nil { - l.Warn().Str("error", err.Error()).Msg("unable to parse session request data") + l.Warn().Str("error", err.Error()).Str("dataRaw", string(message.Data)).Msg("unable to parse session request data") continue } + l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request") if req.OidcGoogle != "" { l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google") @@ -427,7 +406,7 @@ func handleWebRTCSignalWsMessages( metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc() metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime() - err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l) + err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, connectionID, &l) if err != nil { l.Warn().Str("error", err.Error()).Msg("error starting new session") continue @@ -449,14 +428,16 @@ func handleWebRTCSignalWsMessages( l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate") - if currentSession == nil { - l.Warn().Msg("no current session, skipping incoming ICE candidate") + // Find the session this ICE candidate belongs to using the connectionID + session := sessionManager.GetSession(connectionID) + if session == nil { + l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate") continue } - l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session") - if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil { - l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection") + l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session") + if err = session.peerConnection.AddICECandidate(candidate); err != nil { + l.Warn().Str("error", err.Error()).Str("sessionID", session.ID).Msg("failed to add incoming ICE candidate to peer connection") } } } @@ -481,7 +462,16 @@ func handleLogin(c *gin.Context) { return } - config.LocalAuthToken = uuid.New().String() + // Don't generate a new token - use the existing one + // This ensures all sessions can share the same auth token + if config.LocalAuthToken == "" { + // Only generate if we don't have one (shouldn't happen in normal operation) + config.LocalAuthToken = uuid.New().String() + if err := SaveConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"}) + return + } + } // Set the cookie c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true) @@ -490,14 +480,10 @@ func handleLogin(c *gin.Context) { } func handleLogout(c *gin.Context) { - config.LocalAuthToken = "" - if err := SaveConfig(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"}) - return - } - - // Clear the auth cookie + // Only clear the cookies for this session, don't invalidate the token + // The token should remain valid for other sessions c.SetCookie("authToken", "", -1, "/", "", false, true) + c.SetCookie("sessionId", "", -1, "/", "", false, true) // Clear session ID cookie too c.JSON(http.StatusOK, gin.H{"message": "Logout successful"}) } @@ -519,6 +505,38 @@ func protectedMiddleware() gin.HandlerFunc { } } +// requirePermissionMiddleware creates a middleware that enforces specific permissions +func requirePermissionMiddleware(permission Permission) gin.HandlerFunc { + return func(c *gin.Context) { + // Get session ID from cookie + sessionID, err := c.Cookie("sessionId") + if err != nil || sessionID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No session ID found"}) + c.Abort() + return + } + + // Get session from manager + session := sessionManager.GetSession(sessionID) + if session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Session not found"}) + c.Abort() + return + } + + // Check permission + if !session.HasPermission(permission) { + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("Permission denied: %s required", permission)}) + c.Abort() + return + } + + // Store session in context for use by handlers + c.Set("session", session) + c.Next() + } +} + func sendErrorJsonThenAbort(c *gin.Context, status int, message string) { c.JSON(status, gin.H{"error": message}) c.Abort() @@ -591,7 +609,7 @@ func RunWebServer() { logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") if err := r.Run(bindAddress); err != nil { - panic(err) + logger.Fatal().Err(err).Msg("failed to start web server") } } diff --git a/web_tls.go b/web_tls.go index 41f532ea..5d04b031 100644 --- a/web_tls.go +++ b/web_tls.go @@ -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") } } diff --git a/webrtc.go b/webrtc.go index a0a8473b..8e6121bd 100644 --- a/webrtc.go +++ b/webrtc.go @@ -19,13 +19,39 @@ import ( "github.com/rs/zerolog" ) +// Predefined browser string constants for memory efficiency +var ( + BrowserChrome = "chrome" + BrowserFirefox = "firefox" + BrowserSafari = "safari" + BrowserEdge = "edge" + BrowserOpera = "opera" + BrowserUnknown = "user" +) + type Session struct { + ID string + Mode SessionMode + Source string + Identity string + Nickname string + Browser *string // Pointer to predefined browser string constant for memory efficiency + CreatedAt time.Time + LastActive time.Time + LastBroadcast time.Time // Per-session broadcast throttle + + // RPC rate limiting (DoS protection) + rpcRateLimitMu sync.Mutex // Protects rate limit fields + rpcRateLimit int // Count of RPCs in current window + rpcRateLimitWin time.Time // Start of current rate limit window + peerConnection *webrtc.PeerConnection VideoTrack *webrtc.TrackLocalStaticSample ControlChannel *webrtc.DataChannel RPCChannel *webrtc.DataChannel HidChannel *webrtc.DataChannel shouldUmountVirtualMedia bool + flushCandidates func() // Callback to flush buffered ICE candidates rpcQueue chan webrtc.DataChannelMessage @@ -39,6 +65,30 @@ type Session struct { keysDownStateQueue chan usbgadget.KeysDownState } +// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection) +func (s *Session) CheckRPCRateLimit() bool { + const ( + maxRPCPerSecond = 20 + rateLimitWindow = time.Second + ) + + s.rpcRateLimitMu.Lock() + defer s.rpcRateLimitMu.Unlock() + + now := time.Now() + // Reset window if it has expired + if now.Sub(s.rpcRateLimitWin) > rateLimitWindow { + s.rpcRateLimit = 0 + s.rpcRateLimitWin = now + } + + s.rpcRateLimit++ + if s.rpcRateLimit > maxRPCPerSecond { + return false // Rate limit exceeded + } + return true // Within limits +} + func (s *Session) resetKeepAliveTime() { s.keepAliveJitterLock.Lock() defer s.keepAliveJitterLock.Unlock() @@ -55,6 +105,7 @@ type SessionConfig struct { ICEServers []string LocalIP string IsCloud bool + UserAgent string // User agent for browser detection and nickname generation ws *websocket.Conn Logger *zerolog.Logger } @@ -106,7 +157,14 @@ func (s *Session) initQueues() { func (s *Session) handleQueues(index int) { for msg := range s.hidQueue[index] { - onHidMessage(msg, s) + // Get current session from manager to ensure we have the latest state + currentSession := sessionManager.GetSession(s.ID) + if currentSession != nil { + onHidMessage(msg, currentSession) + } else { + // Session was removed, use original to avoid nil panic + onHidMessage(msg, s) + } } } @@ -218,7 +276,10 @@ func newSession(config SessionConfig) (*Session, error) { return nil, err } - session := &Session{peerConnection: peerConnection} + session := &Session{ + peerConnection: peerConnection, + Browser: extractBrowserFromUserAgent(config.UserAgent), + } session.rpcQueue = make(chan webrtc.DataChannelMessage, 256) session.initQueues() session.initKeysDownStateQueue() @@ -226,7 +287,16 @@ func newSession(config SessionConfig) (*Session, error) { go func() { for msg := range session.rpcQueue { // TODO: only use goroutine if the task is asynchronous - go onRPCMessage(msg, session) + go func(m webrtc.DataChannelMessage) { + // Get current session from manager to ensure we have the latest state + currentSession := sessionManager.GetSession(session.ID) + if currentSession != nil { + onRPCMessage(m, currentSession) + } else { + // Session was removed, use original to avoid nil panic + onRPCMessage(m, session) + } + }(msg) } }() @@ -262,9 +332,9 @@ func newSession(config SessionConfig) (*Session, error) { triggerVideoStateUpdate() triggerUSBStateUpdate() case "terminal": - handleTerminalChannel(d) + handleTerminalChannel(d, session) case "serial": - handleSerialChannel(d) + handleSerialChannel(d, session) default: if strings.HasPrefix(d.Label(), uploadIdPrefix) { go handleUploadChannel(d) @@ -297,9 +367,23 @@ func newSession(config SessionConfig) (*Session, error) { }() var isConnected bool + // Buffer to hold ICE candidates until answer is sent + var candidateBuffer []webrtc.ICECandidateInit + var candidateBufferMutex sync.Mutex + var answerSent bool + peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate") if candidate != nil { + candidateBufferMutex.Lock() + if !answerSent { + // Buffer the candidate until answer is sent + candidateBuffer = append(candidateBuffer, candidate.ToJSON()) + candidateBufferMutex.Unlock() + return + } + candidateBufferMutex.Unlock() + err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()}) if err != nil { scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel") @@ -307,8 +391,88 @@ func newSession(config SessionConfig) (*Session, error) { } }) + // Store the callback to flush buffered candidates + session.flushCandidates = func() { + candidateBufferMutex.Lock() + answerSent = true + // Send all buffered candidates + for _, candidate := range candidateBuffer { + err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate}) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to write buffered new-ice-candidate to WebRTC signaling channel") + } + } + candidateBuffer = nil + candidateBufferMutex.Unlock() + } + + // Track cleanup state to prevent double cleanup + var cleanedUp bool + var cleanupMutex sync.Mutex + + cleanupSession := func(reason string) { + cleanupMutex.Lock() + defer cleanupMutex.Unlock() + + if cleanedUp { + return + } + cleanedUp = true + + scopedLogger.Info(). + Str("sessionID", session.ID). + Str("reason", reason). + Msg("Cleaning up session") + + // Remove from session manager + sessionManager.RemoveSession(session.ID) + + // Cancel any ongoing keyboard macro if session has permission + if session.HasPermission(PermissionPaste) { + cancelKeyboardMacro() + } + + // Stop RPC processor + if session.rpcQueue != nil { + close(session.rpcQueue) + session.rpcQueue = nil + } + + // Stop HID RPC processor + for i := 0; i < len(session.hidQueue); i++ { + if session.hidQueue[i] != nil { + close(session.hidQueue[i]) + session.hidQueue[i] = nil + } + } + + if session.keysDownStateQueue != nil { + close(session.keysDownStateQueue) + session.keysDownStateQueue = nil + } + + if session.shouldUmountVirtualMedia { + if err := rpcUnmountImage(); err != nil { + scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") + } + } + + if isConnected { + isConnected = false + actionSessions-- + onActiveSessionsChanged() + if actionSessions == 0 { + onLastSessionDisconnected() + } + } + } + peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { - scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed") + scopedLogger.Info(). + Str("sessionID", session.ID). + Str("connectionState", connectionState.String()). + Msg("ICE Connection State has changed") + if connectionState == webrtc.ICEConnectionStateConnected { if !isConnected { isConnected = true @@ -319,46 +483,27 @@ func newSession(config SessionConfig) (*Session, error) { } } } - //state changes on closing browser tab disconnected->failed, we need to manually close it + + // Handle disconnection and failure states + if connectionState == webrtc.ICEConnectionStateDisconnected { + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is disconnected, connection may recover") + } + if connectionState == webrtc.ICEConnectionStateFailed { - scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection") + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is failed, closing peerConnection and cleaning up") + cleanupSession("ice-failed") _ = peerConnection.Close() } + if connectionState == webrtc.ICEConnectionStateClosed { - scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") - if session == currentSession { - // Cancel any ongoing keyboard report multi when session closes - cancelKeyboardMacro() - currentSession = nil - } - // Stop RPC processor - if session.rpcQueue != nil { - close(session.rpcQueue) - session.rpcQueue = nil - } - - // Stop HID RPC processor - for i := 0; i < len(session.hidQueue); i++ { - close(session.hidQueue[i]) - session.hidQueue[i] = nil - } - - close(session.keysDownStateQueue) - session.keysDownStateQueue = nil - - if session.shouldUmountVirtualMedia { - if err := rpcUnmountImage(); err != nil { - scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") - } - } - if isConnected { - isConnected = false - actionSessions-- - onActiveSessionsChanged() - if actionSessions == 0 { - onLastSessionDisconnected() - } - } + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is closed, cleaning up") + cleanupSession("ice-closed") } }) return session, nil From b3222556842e0d88cbb86a140e9c0124bf976d72 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 20:10:22 +0300 Subject: [PATCH 2/7] 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. --- cloud.go | 10 +- config.go | 34 +- datachannel_helpers.go | 2 +- deploy.log | 999 ++++++++++++++++++ errors.go | 2 +- internal/session/permissions.go | 132 +-- internal/session/types.go | 2 +- jsonrpc.go | 28 +- main.go | 8 +- session_manager.go | 88 +- session_permissions.go | 2 +- ui/src/api/sessionApi.ts | 45 +- ui/src/components/AccessDeniedOverlay.tsx | 12 +- ui/src/components/ActionBar.tsx | 7 +- ui/src/components/NicknameModal.tsx | 11 +- ui/src/components/SessionControlPanel.tsx | 19 +- ui/src/components/SessionsList.tsx | 1 + .../UnifiedSessionRequestDialog.tsx | 4 +- ui/src/components/WebRTCVideo.tsx | 12 +- ui/src/components/popovers/SessionPopover.tsx | 17 +- ui/src/hooks/usePermissions.ts | 17 +- ui/src/hooks/useSessionEvents.ts | 17 +- ui/src/hooks/useSessionManagement.ts | 26 +- .../devices.$id.settings.multi-session.tsx | 7 +- ui/src/routes/devices.$id.settings.tsx | 3 +- ui/src/routes/devices.$id.tsx | 7 +- ui/src/utils/nicknameGenerator.ts | 11 +- web.go | 26 +- webrtc.go | 34 +- 29 files changed, 1299 insertions(+), 284 deletions(-) create mode 100644 deploy.log diff --git a/cloud.go b/cloud.go index 33fa7377..479d37b0 100644 --- a/cloud.go +++ b/cloud.go @@ -535,11 +535,11 @@ func handleSessionRequest( } err = wsjson.Write(context.Background(), c, gin.H{ - "type": "answer", - "data": sd, - "sessionId": session.ID, - "mode": session.Mode, - "nickname": session.Nickname, + "type": "answer", + "data": sd, + "sessionId": session.ID, + "mode": session.Mode, + "nickname": session.Nickname, "requireNickname": requireNickname, "requireApproval": requireApproval, }) diff --git a/config.go b/config.go index bc8b463d..024a5844 100644 --- a/config.go +++ b/config.go @@ -79,10 +79,10 @@ func (m *KeyboardMacro) Validate() error { // 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"` + 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"` } @@ -139,15 +139,15 @@ func (c *Config) SetDisplayRotation(rotation string) error { const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - CloudAppURL: "https://app.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value - ActiveExtension: "", + CloudURL: "https://api.jetkvm.com", + CloudAppURL: "https://app.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + ActiveExtension: "", MultiSession: &MultiSessionConfig{ - Enabled: true, // Enable by default for new features - MaxSessions: 10, // Reasonable default - PrimaryTimeout: 300, // 5 minutes - AllowCloudOverride: true, // Cloud sessions can take control + 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{}, @@ -157,12 +157,12 @@ var defaultConfig = &Config{ DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes SessionSettings: &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, // 10 seconds default - PrivateKeystrokes: false, // By default, share keystrokes with observers + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, // 10 seconds default + PrivateKeystrokes: false, // By default, share keystrokes with observers }, - JigglerEnabled: false, + JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ InactivityLimitSeconds: 60, diff --git a/datachannel_helpers.go b/datachannel_helpers.go index dd1ad2da..8edfd095 100644 --- a/datachannel_helpers.go +++ b/datachannel_helpers.go @@ -4,7 +4,7 @@ import "github.com/pion/webrtc/v4" func handlePermissionDeniedChannel(d *webrtc.DataChannel, message string) { d.OnOpen(func() { - d.SendText(message + "\r\n") + _ = d.SendText(message + "\r\n") d.Close() }) d.OnMessage(func(msg webrtc.DataChannelMessage) {}) diff --git a/deploy.log b/deploy.log new file mode 100644 index 00000000..31640d15 --- /dev/null +++ b/deploy.log @@ -0,0 +1,999 @@ +▶ Building frontend(B + +added 429 packages, and audited 430 packages in 16s + +142 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities + +> kvm-ui@2025.10.01.1900 build:device +> tsc && vite build --mode=device --emptyOutDir + +vite v7.1.7 building for device... +transforming... +✓ 2995 modules transformed. +rendering chunks... +20:14:45 info [plugin vite:reporter] +20:14:45 info (!) /workspaces/kvm/ui/src/hooks/hidRpc.ts is dynamically imported by /workspaces/kvm/ui/src/hooks/usePermissions.ts but also statically imported by /workspaces/kvm/ui/src/hooks/useHidRpc.ts, /workspaces/kvm/ui/src/hooks/useKeyboard.ts, dynamic import will not move module into another chunk. +20:14:45 info  +computing gzip size... +../static/index.html 2.84 kB │ gzip: 0.95 kB +../static/assets/immutable/netboot-icon-OoGRDuxL.svg 10.60 kB │ gzip: 4.51 kB +../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png 13.38 kB +../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2 68.28 kB +../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2 69.73 kB +../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2 69.82 kB +../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2 70.32 kB +../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2 70.87 kB +../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2 70.92 kB +../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2 71.26 kB +../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2 72.14 kB +../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2 72.99 kB +../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2 73.33 kB +../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2 73.63 kB +../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2 73.63 kB +../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2 73.76 kB +../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2 74.18 kB +../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2 75.12 kB +../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2 76.17 kB +../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png 188.53 kB +../static/assets/immutable/vendor-CCFZcsOx.css 3.55 kB │ gzip: 1.08 kB +../static/assets/immutable/index-3KaWki3G.css 108.89 kB │ gzip: 16.78 kB +../static/assets/immutable/AutoHeight-DNMbSxBi.js 0.40 kB │ gzip: 0.30 kB +../static/assets/immutable/FeatureFlag-CpBy_yIc.js 0.56 kB │ gzip: 0.39 kB +../static/assets/immutable/login-jvmJO2K9.js 0.65 kB │ gzip: 0.40 kB +../static/assets/immutable/signup-Dcu7CrZF.js 0.68 kB │ gzip: 0.40 kB +../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js 0.86 kB │ gzip: 0.57 kB +../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js 0.94 kB │ gzip: 0.53 kB +../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js 1.04 kB │ gzip: 0.54 kB +../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js 1.09 kB │ gzip: 0.56 kB +../static/assets/immutable/devices._id.other-session-DOmnPqBd.js 1.12 kB │ gzip: 0.57 kB +../static/assets/immutable/devices.already-adopted-CUKof-fH.js 1.20 kB │ gzip: 0.58 kB +../static/assets/immutable/Checkbox-DfeYpuQt.js 1.27 kB │ gzip: 0.66 kB +../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js 1.66 kB │ gzip: 0.83 kB +../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js 1.74 kB │ gzip: 0.77 kB +../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js 1.80 kB │ gzip: 0.81 kB +../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js 1.95 kB │ gzip: 1.01 kB +../static/assets/immutable/Terminal-DrfJbtdJ.js 3.18 kB │ gzip: 1.63 kB +../static/assets/immutable/AuthLayout-BU_zUyj5.js 3.85 kB │ gzip: 1.73 kB +../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js 5.81 kB │ gzip: 1.85 kB +../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js 5.98 kB │ gzip: 2.12 kB +../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js 6.01 kB │ gzip: 2.09 kB +../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js 6.17 kB │ gzip: 1.54 kB +../static/assets/immutable/devices._id.settings.video-w6XP12DM.js 6.88 kB │ gzip: 2.60 kB +../static/assets/immutable/devices._id.settings-DQ5hSmGr.js 8.77 kB │ gzip: 1.63 kB +../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js 8.77 kB │ gzip: 3.05 kB +../static/assets/immutable/connectionStats-BTh-HMMJ.js 8.92 kB │ gzip: 3.57 kB +../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js 9.70 kB │ gzip: 2.49 kB +../static/assets/immutable/MacroForm-DlAcnh76.js 10.00 kB │ gzip: 3.55 kB +../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js 10.82 kB │ gzip: 3.17 kB +../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js 15.47 kB │ gzip: 3.30 kB +../static/assets/immutable/devices._id.mount-eRr7fRnt.js 32.30 kB │ gzip: 14.11 kB +../static/assets/immutable/index-ITvbnH2t.js 249.49 kB │ gzip: 64.18 kB +../static/assets/immutable/vendor-C8gpwofI.js 1,568.17 kB │ gzip: 469.07 kB +20:14:46 info  +20:14:46 info (!) Some chunks are larger than 500 kB after minification. Consider: +20:14:46 info - Using dynamic import() to code-split the application +20:14:46 info - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +20:14:46 info - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 9.00s +20:14:46 info ../static/favicon.ico: 92.7% -- created ../static/favicon.ico.gz +20:14:46 info ../static/index.html: 67.0% -- created ../static/index.html.gz +20:14:46 info ../static/web-app-manifest-192x192.png: -0.3% -- created ../static/web-app-manifest-192x192.png.gz +20:14:46 info ../static/apple-touch-icon.png: -0.3% -- created ../static/apple-touch-icon.png.gz +20:14:46 info ../static/favicon.png: -0.4% -- created ../static/favicon.png.gz +20:14:46 info ../static/web-app-manifest-512x512.png: 5.1% -- created ../static/web-app-manifest-512x512.png.gz +20:14:46 info ../static/favicon-96x96.png: 0.0% -- created ../static/favicon-96x96.png.gz +20:14:46 info ../static/sse.html: 77.3% -- created ../static/sse.html.gz +20:14:46 info ../static/fonts/CircularXXWeb-Regular.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Regular.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-ExtraBlackItalic.woff2: 0.0% -- created ../static/fonts/CircularXXWeb-ExtraBlackItalic.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-BookItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-BookItalic.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-Black.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Black.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-Medium.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Medium.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-BlackItalic.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-BlackItalic.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-Book.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Book.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-Light.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Light.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-Bold.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Bold.woff2.gz +20:14:46 info ../static/fonts/CircularXXWeb-MediumItalic.woff2: 0.0% -- created ../static/fonts/CircularXXWeb-MediumItalic.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-Thin.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-Thin.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-LightItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-LightItalic.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-Italic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Italic.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-BoldItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-BoldItalic.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-ExtraBlack.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-ExtraBlack.woff2.gz +20:14:47 info ../static/fonts/CircularXXWeb-ThinItalic.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-ThinItalic.woff2.gz +20:14:47 info ../static/fonts/fonts.css: 89.4% -- created ../static/fonts/fonts.css.gz +20:14:47 info ../static/assets/immutable/devices._id.settings.video-w6XP12DM.js: 62.8% -- created ../static/assets/immutable/devices._id.settings.video-w6XP12DM.js.gz +20:14:47 info ../static/assets/immutable/vendor-C8gpwofI.js: 70.3% -- created ../static/assets/immutable/vendor-C8gpwofI.js.gz +20:14:47 info ../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2.gz +20:14:47 info ../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js: 68.4% -- created ../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js.gz +20:14:47 info ../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2.gz +20:14:47 info ../static/assets/immutable/index-ITvbnH2t.js: 74.6% -- created ../static/assets/immutable/index-ITvbnH2t.js.gz +20:14:47 info ../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png: 0.6% -- created ../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png.gz +20:14:47 info ../static/assets/immutable/AutoHeight-DNMbSxBi.js: 29.0% -- created ../static/assets/immutable/AutoHeight-DNMbSxBi.js.gz +20:14:47 info ../static/assets/immutable/AuthLayout-BU_zUyj5.js: 55.6% -- created ../static/assets/immutable/AuthLayout-BU_zUyj5.js.gz +20:14:47 info ../static/assets/immutable/MacroForm-DlAcnh76.js: 64.8% -- created ../static/assets/immutable/MacroForm-DlAcnh76.js.gz +20:14:47 info ../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js: 78.8% -- created ../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js.gz +20:14:47 info ../static/assets/immutable/index-3KaWki3G.css: 84.9% -- created ../static/assets/immutable/index-3KaWki3G.css.gz +20:14:47 info ../static/assets/immutable/FeatureFlag-CpBy_yIc.js: 34.8% -- created ../static/assets/immutable/FeatureFlag-CpBy_yIc.js.gz +20:14:47 info ../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2.gz +20:14:47 info ../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js: 36.3% -- created ../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js.gz +20:14:47 info ../static/assets/immutable/login-jvmJO2K9.js: 41.1% -- created ../static/assets/immutable/login-jvmJO2K9.js.gz +20:14:47 info ../static/assets/immutable/devices.already-adopted-CUKof-fH.js: 52.5% -- created ../static/assets/immutable/devices.already-adopted-CUKof-fH.js.gz +20:14:47 info ../static/assets/immutable/devices._id.mount-eRr7fRnt.js: 56.6% -- created ../static/assets/immutable/devices._id.mount-eRr7fRnt.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js: 49.8% -- created ../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2.gz +20:14:48 info ../static/assets/immutable/signup-Dcu7CrZF.js: 43.2% -- created ../static/assets/immutable/signup-Dcu7CrZF.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js: 48.8% -- created ../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js: 65.7% -- created ../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js: 65.6% -- created ../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2.gz +20:14:48 info ../static/assets/immutable/Terminal-DrfJbtdJ.js: 49.2% -- created ../static/assets/immutable/Terminal-DrfJbtdJ.js.gz +20:14:48 info ../static/assets/immutable/devices._id.other-session-DOmnPqBd.js: 50.2% -- created ../static/assets/immutable/devices._id.other-session-DOmnPqBd.js.gz +20:14:48 info ../static/assets/immutable/Checkbox-DfeYpuQt.js: 49.7% -- created ../static/assets/immutable/Checkbox-DfeYpuQt.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js: 70.8% -- created ../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js.gz +20:14:48 info ../static/assets/immutable/connectionStats-BTh-HMMJ.js: 60.5% -- created ../static/assets/immutable/connectionStats-BTh-HMMJ.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js: 50.8% -- created ../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2: 0.0% -- created ../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2: 0.0% -- created ../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js: 56.6% -- created ../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js.gz +20:14:48 info ../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2.gz +20:14:48 info ../static/assets/immutable/devices._id.settings-DQ5hSmGr.js: 81.6% -- created ../static/assets/immutable/devices._id.settings-DQ5hSmGr.js.gz +20:14:48 info ../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js: 56.3% -- created ../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js.gz +20:14:48 info ../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js: 50.6% -- created ../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js: 65.0% -- created ../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js.gz +20:14:48 info ../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js: 75.3% -- created ../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js.gz +20:14:49 info ../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js: 74.6% -- created ../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js.gz +20:14:49 info ../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2.gz +20:14:49 info ../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2.gz +20:14:49 info ../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js: 44.7% -- created ../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js.gz +20:14:49 info ../static/assets/immutable/netboot-icon-OoGRDuxL.svg: 56.5% -- created ../static/assets/immutable/netboot-icon-OoGRDuxL.svg.gz +20:14:49 info ../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2.gz +20:14:49 info ../static/assets/immutable/vendor-CCFZcsOx.css: 70.1% -- created ../static/assets/immutable/vendor-CCFZcsOx.css.gz +20:14:49 info ../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2.gz +20:14:49 info ../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png: 12.6% -- created ../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png.gz +20:14:49 info ../static/jetkvm.svg: 72.2% -- created ../static/jetkvm.svg.gz +20:14:49 info ../static/favicon.svg: 70.1% -- created ../static/favicon.svg.gz +▶ Building release binary(B +▶ Building the project in host ...(B +20:14:49 info + make build_release SKIP_NATIVE_IF_EXISTS=0 SKIP_UI_BUILD=1 +Skipping frontend build... +Building native... +▶ Generating UI index(B +ui_index.c has been generated successfully. +▶ Building native library(B +Re-run cmake no build system arguments +-- Using defconfig: /workspaces/kvm/internal/native/cgo/lvgl_defconfig +-- Converted to absolute path: /workspaces/kvm/internal/native/cgo/lvgl_defconfig +['/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/scripts/kconfig.py', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/Kconfig', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/.config', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/autoconf.h', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/kconfig_list', '/workspaces/kvm/internal/native/cgo/lvgl_defconfig'] +Parsing /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/Kconfig +Loaded configuration '/workspaces/kvm/internal/native/cgo/lvgl_defconfig' +No change to configuration in '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/.config' +No change to Kconfig header in '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/autoconf.h' +Failed to locate pcpp - installing it +PCPP is already installed in venv +Preprocessing completed. Output saved to /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/tmp.h +Expanded configuration header saved to /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/lv_conf_expanded.h +Temporary preprocessed file /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/tmp.h removed. +-- Enabling the building of ThorVG internal +-- Configuring done (3.8s) +-- Generating done (0.3s) +-- Build files have been written to: /workspaces/kvm/internal/native/cgo/build +▶ Copying built library and header files(B +gmake[1]: Entering directory '/workspaces/kvm/internal/native/cgo/build' +20:14:55 info gmake[1]: Warning: File 'Makefile' has modification time 0.11 s in the future +20:14:56 info gmake[3]: Warning: File '_deps/lvgl-build/CMakeFiles/lvgl_thorvg.dir/compiler_depend.make' has modification time 0.32 s in the future +20:14:56 info gmake[3]: warning: Clock skew detected. Your build may be incomplete. +[ 7%] Built target lvgl_thorvg +[ 51%] Built target lvgl +[ 52%] Building C object CMakeFiles/jknative.dir/ui_index.c.o +[ 52%] Linking CXX static library libjknative.a +[100%] Built target jknative +Install the project... +-- Install configuration: "Release" +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_templ.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_async.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_timer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_rb.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_math.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_image_header_cache.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_image_cache.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_cache_instance.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_class.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_lru_ll.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_lru_rb.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_entry.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler_builtin.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_array.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_bidi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_grad.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_palette.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_assert.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_types.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_event.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style_gen.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_iter.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim_timeline.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text_ap.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_circle_buf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_fs.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color_op.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_area.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_log.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_lru.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_matrix.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_ll.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_tree.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx/lv_qnx.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_display.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_gnu_efi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_edk2.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_std_wrapper.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_indev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_context.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev/lv_evdev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_xkb.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_libinput.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/lv_drivers.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/wayland +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_keyboard.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_window.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_mousewheel.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_mouse.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11/lv_x11.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796/lv_st7796.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm/lv_linux_drm.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb/lv_linux_fbdev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc/lv_renesas_glcdc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735/lv_st7735.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x/lv_ft81x_defines.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x/lv_ft81x.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd/lv_lcd_generic_mipi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc/lv_st_ltdc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341/lv_ili9341.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi/lv_tft_espi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789/lv_st7789.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/glfw +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/windows +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_entry.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_profiler.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_cache.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_image_cache.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_lcd.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_libuv.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_fbdev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_touchscreen.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_group.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_event.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_property.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style_gen.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_pos.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_scroll.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_tree.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_class.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_refr.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_global.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_draw.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v9_1.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_init.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v8.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_cmsis_rtos2.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_pthread.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_mqx.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_sdl2.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_windows.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_rtthread.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_freertos.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os_none.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_conf_kconfig.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot/lv_snapshot.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime/lv_ime_pinyin.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg/vg_lite.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_display.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_indev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_indev_gesture.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_helpers.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_screenshot_compare.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer/lv_file_explorer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer/lv_observer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_widget.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_checkbox_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_dropdown_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_keyboard_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_textarea_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_canvas_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_chart_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_slider_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_spangroup_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_tabview_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_obj_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_calendar_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_roller_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_table_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_label_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_image_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_event_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_buttonmatrix_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_button_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_scale_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_arc_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_bar_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_base_types.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_component.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_update.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_style.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment/lv_fragment.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav/lv_gridnav.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey/lv_monkey.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon/lv_sysmon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont/lv_imgfont.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager/lv_font_manager.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager/lv_font_manager_recycle.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_parser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_decoder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_token.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_render.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgUtil.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieProperty.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieInterpolator.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgFill.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieModifier.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieModel.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgArray.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLoadModule.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgLoaderCommon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgPicture.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/config.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLoader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCommon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgText.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterAvx.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieParserHandler.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg_capi.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgScene.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterTexmap.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgRender.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLock.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieParser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgPaint.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieBuilder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgStr.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCanvas.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterC.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgTaskScheduler.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieExpressions.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieLoader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgIteratorAccessor.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieCommon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgPath.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgInlist.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgSceneBuilder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgLoader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgMath.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRenderer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgRawLoader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/fwd.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/memorystream.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/reader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/prettywriter.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/ostreamwrapper.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/encodedstream.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/filereadstream.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/cursorstreamwrapper.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/istreamwrapper.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/uri.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/stringbuffer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/ieee754.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/strtod.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/swap.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/regex.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/diyfp.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/biginteger.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/strfunc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/itoa.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/stack.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/dtoa.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/clzll.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/meta.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/pow10.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/encodings.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/schema.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/stream.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/filewritestream.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/rapidjson.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/document.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/allocators.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/writer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error/error.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error/en.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/memorybuffer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/pointer.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes/inttypes.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes/stdint.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgShape.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSaveModule.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgBinaryDesc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg_lottie.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterNeon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgAnimation.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgCssStyle.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieRenderPooler.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgXmlParser.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgFrameModule.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCompressor.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwCommon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo/lv_libjpeg_turbo.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg/lv_ffmpeg.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng/lv_libpng.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/tjpgd.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/tjpgdcnf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/lv_tjpgd.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/stb_truetype_htcw.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/stb_rect_pack.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/lv_tiny_ttf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/ftoption.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/lv_freetype.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/ftmodule.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat_config.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/internal.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmltok.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/nametab.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/siphash.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/latin1tab.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat_external.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/winconfig.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmltok_impl.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/utf8tab.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/ascii.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmlrole.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/asciitab.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/iasciitab.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4 +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4/lz4.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/lv_qrcode.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/qrcodegen.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle/lv_rle.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng/lodepng.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng/lv_lodepng.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder/lv_bin_decoder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie/lv_rlottie.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv/lv_fsdrv.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp/lv_bmp.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/code128.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/lv_barcode.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/gifdec_mve.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/lv_gif.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/gifdec.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v9_0.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_conf_internal.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/lv_layout.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex/lv_flex.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid/lv_grid.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_sprintf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/uefi +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/clib +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/micropython +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_string.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_mem.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/rtthread +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin/lv_tlsf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_gesture.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_scroll.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display/lv_display.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_binfont_loader.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font_fmt_txt.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_symbol_def.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lvgl.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default/lv_theme_default.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple/lv_theme_simple.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/lv_theme.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono/lv_theme_mono.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_grad.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_mask.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_l8.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium/lv_blend_helium.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_al88.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb888.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565_swapped.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d/lv_blend_arm2d.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888_premultiplied.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon/lv_blend_neon.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d/lv_draw_sw_arm2d.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d/lv_draw_sw_helium.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_decoder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_g2d_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_g2d_buf_map.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_draw_g2d.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_cfg.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_draw_pxp.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_osa.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_matrix.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_draw_vglite.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_buf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_path.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_label.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_mask.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_dsc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_triangle.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_vector.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_3d.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_line.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl/lv_draw_sdl.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_draw_vg_lite.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_path.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_draw_vg_lite_type.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_utils.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_math.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_stroke.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_decoder.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_pending.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_grad.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_image.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nema_gfx +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/dma2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_rect.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_arc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/opengles +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas/dave2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_buf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture/lv_3dtexture.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown/lv_dropdown.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_header_arrow.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_header_dropdown.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_chinese.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ/lv_objx_templ.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale/lv_scale.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led/lv_led.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property/lv_obj_property_names.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property/lv_style_properties.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win/lv_win.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart/lv_chart.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox/lv_checkbox.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label/lv_label.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox/lv_spinbox.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage/lv_animimage.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider/lv_slider.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix/lv_buttonmatrix.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner/lv_spinner.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image/lv_image.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas/lv_canvas.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview/lv_tabview.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox/lv_msgbox.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea/lv_textarea.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button/lv_button.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span/lv_span.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton/lv_imagebutton.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line/lv_line.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table/lv_table.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list/lv_list.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu/lv_menu.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch/lv_switch.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie/lv_lottie.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar/lv_bar.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc/lv_arc.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller/lv_roller.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview/lv_tileview.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard/lv_keyboard.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick/lv_tick.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_bidi_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color_op_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_area_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_fs_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_entry_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler_builtin_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_timer_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_rb_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_event_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev/lv_evdev_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_xkb_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_libinput_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/wayland +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/glfw +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/windows +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_group_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_draw_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_refr_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_scroll_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_event_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_class_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_linux_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime/lv_ime_pinyin_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer/lv_file_explorer_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer/lv_observer_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_component_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment/lv_fragment_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey/lv_monkey_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon/lv_sysmon_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg/lv_ffmpeg_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/lv_freetype_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4 +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/lv_qrcode_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie/lv_rlottie_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/lv_barcode_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/lv_gif_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/lv_layout_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lvgl_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/uefi +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/clib +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/micropython +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_mem_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/rtthread +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin/lv_tlsf_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_gesture_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display/lv_display_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font_fmt_txt_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/lv_theme_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_label_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_mask_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_buf_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_image_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_mask_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_rect_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nema_gfx +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_triangle_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/dma2d +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/opengles +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_decoder_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas/dave2d +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_vector_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture/lv_3dtexture_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown/lv_dropdown_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale/lv_scale_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led/lv_led_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win/lv_win_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart/lv_chart_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox/lv_checkbox_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label/lv_label_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox/lv_spinbox_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage/lv_animimage_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider/lv_slider_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix/lv_buttonmatrix_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image/lv_image_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas/lv_canvas_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview/lv_tabview_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox/lv_msgbox_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea/lv_textarea_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button/lv_button_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span/lv_span_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton/lv_imagebutton_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line/lv_line_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table/lv_table_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu/lv_menu_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch/lv_switch_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie/lv_lottie_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar/lv_bar_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc/lv_arc_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller/lv_roller_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview/lv_tileview_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard/lv_keyboard_private.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick/lv_tick_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_conf.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/lib/liblvgl.a +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/share/pkgconfig/lvgl.pc +-- Installing: /tmp/tmp.w0Bd5jwcNB/lib/liblvgl_thorvg.a +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h +-- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h +-- Installing: /tmp/tmp.w0Bd5jwcNB/lib/libjknative.a +20:15:14 info gmake[1]: warning: Clock skew detected. Your build may be incomplete. +gmake[1]: Leaving directory '/workspaces/kvm/internal/native/cgo/build' +Building release... +GOOS=linux GOARCH=arm GOARM=7 ARCHFLAGS="-arch arm" CGO_CFLAGS="-I/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/include -I/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/sysroot/usr/include" CGO_LDFLAGS="-L/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/lib -L/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/sysroot/usr/lib -lrockit -lrockchip_mpp -lrga -lpthread -lm" CC="/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc" LD="/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-ld" CGO_ENABLED=1 go build \ + -ldflags="-s -w -X github.com/prometheus/common/version.Branch=feat/multisession-support -X github.com/prometheus/common/version.BuildDate=2025-10-08T17:14:49+0000 -X github.com/prometheus/common/version.Revision=81bc5055dbcb97923288f523a97ffc892fe682f7 -X github.com/jetkvm/kvm.builtTimestamp=1759943689 -X github.com/jetkvm/kvm.builtAppVersion=0.4.8" \ + -trimpath -tags netgo,timetzdata,nomsgpack \ + -o bin/jetkvm_app cmd/main.go +20:15:19 info + set +x +Deployment complete. +20:15:31 error Error tunneling to container: wait: remote command exited without exit status or exit signal diff --git a/errors.go b/errors.go index b1d9f698..b287f938 100644 --- a/errors.go +++ b/errors.go @@ -7,4 +7,4 @@ var ( ErrPermissionDeniedMouse = errors.New("permission denied: mouse input") ErrNotPrimarySession = errors.New("operation requires primary session") ErrSessionNotFound = errors.New("session not found") -) \ No newline at end of file +) diff --git a/internal/session/permissions.go b/internal/session/permissions.go index 6fd10e3b..3c60cd20 100644 --- a/internal/session/permissions.go +++ b/internal/session/permissions.go @@ -15,9 +15,9 @@ const ( PermissionPaste Permission = "clipboard.paste" // Session management permissions - PermissionSessionTransfer Permission = "session.transfer" - PermissionSessionApprove Permission = "session.approve" - PermissionSessionKick Permission = "session.kick" + 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" @@ -35,8 +35,8 @@ const ( PermissionExtensionManage Permission = "extension.manage" // Terminal/Serial permissions - PermissionTerminalAccess Permission = "terminal.access" - PermissionSerialAccess Permission = "serial.access" + PermissionTerminalAccess Permission = "terminal.access" + PermissionSerialAccess Permission = "serial.access" PermissionExtensionATX Permission = "extension.atx" PermissionExtensionDC Permission = "extension.dc" PermissionExtensionSerial Permission = "extension.serial" @@ -78,7 +78,7 @@ var RolePermissions = map[SessionMode]PermissionSet{ PermissionExtensionWOL: true, PermissionSettingsRead: true, PermissionSettingsWrite: true, - PermissionSettingsAccess: true, // Only primary can access settings UI + PermissionSettingsAccess: true, // Only primary can access settings UI PermissionSystemReboot: true, PermissionSystemUpdate: true, PermissionSystemNetwork: true, @@ -140,20 +140,20 @@ func RequirePermissionForMode(mode SessionMode, perm Permission) error { // GetPermissionsResponse is the response structure for getPermissions RPC type GetPermissionsResponse struct { - Mode string `json:"mode"` - Permissions map[string]bool `json:"permissions"` + 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, + "setATXPowerAction": PermissionPowerControl, + "setDCPowerState": PermissionPowerControl, + "setDCRestoreState": PermissionPowerControl, // USB device control - "setUsbDeviceState": PermissionUSBControl, - "setUsbDevices": PermissionUSBControl, + "setUsbDeviceState": PermissionUSBControl, + "setUsbDevices": PermissionUSBControl, // Mount operations "mountUsb": PermissionMountMedia, @@ -196,43 +196,43 @@ var MethodPermissions = map[string]Permission{ "setBacklightSettings": PermissionSettingsWrite, // USB/HID settings - "setUsbEmulationState": PermissionSettingsWrite, - "setUsbConfig": PermissionSettingsWrite, - "setKeyboardLayout": PermissionSettingsWrite, - "setJigglerState": PermissionSettingsWrite, - "setJigglerConfig": PermissionSettingsWrite, - "setMassStorageMode": PermissionSettingsWrite, - "setKeyboardMacros": PermissionSettingsWrite, - "setWakeOnLanDevices": PermissionSettingsWrite, + "setUsbEmulationState": PermissionSettingsWrite, + "setUsbConfig": PermissionSettingsWrite, + "setKeyboardLayout": PermissionSettingsWrite, + "setJigglerState": PermissionSettingsWrite, + "setJigglerConfig": PermissionSettingsWrite, + "setMassStorageMode": PermissionSettingsWrite, + "setKeyboardMacros": PermissionSettingsWrite, + "setWakeOnLanDevices": PermissionSettingsWrite, // Cloud settings - "setCloudUrl": PermissionSettingsWrite, - "deregisterDevice": PermissionSettingsWrite, + "setCloudUrl": PermissionSettingsWrite, + "deregisterDevice": PermissionSettingsWrite, // Active extension control - "setActiveExtension": PermissionExtensionManage, + "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, + "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, + "approveNewSession": PermissionSessionApprove, + "denyNewSession": PermissionSessionApprove, + "transferSession": PermissionSessionTransfer, + "transferPrimary": PermissionSessionTransfer, + "requestPrimary": PermissionSessionRequestPrimary, + "releasePrimary": PermissionSessionReleasePrimary, // Extension operations - "activateExtension": PermissionExtensionManage, - "deactivateExtension": PermissionExtensionManage, - "sendWOLMagicPacket": PermissionExtensionWOL, + "activateExtension": PermissionExtensionManage, + "deactivateExtension": PermissionExtensionManage, + "sendWOLMagicPacket": PermissionExtensionWOL, // Read operations - require appropriate read permissions "getSessionSettings": PermissionSettingsRead, @@ -266,41 +266,41 @@ var MethodPermissions = map[string]Permission{ "getNetworkState": PermissionSettingsRead, // Mount/media read operations - "getMassStorageMode": PermissionMountList, - "getUsbState": PermissionMountList, - "getUSBState": PermissionMountList, - "listStorageFiles": PermissionMountList, - "getStorageSpace": PermissionMountList, + "getMassStorageMode": PermissionMountList, + "getUsbState": PermissionMountList, + "getUSBState": PermissionMountList, + "listStorageFiles": PermissionMountList, + "getStorageSpace": PermissionMountList, // Extension read operations - "getActiveExtension": PermissionSettingsRead, + "getActiveExtension": PermissionSettingsRead, // Power state reads - "getATXState": PermissionSettingsRead, - "getDCPowerState": PermissionSettingsRead, - "getDCRestoreState": PermissionSettingsRead, + "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, + "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 -} \ No newline at end of file +} diff --git a/internal/session/types.go b/internal/session/types.go index 50348d0e..1a983bdb 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -8,4 +8,4 @@ const ( SessionModeObserver SessionMode = "observer" SessionModeQueued SessionMode = "queued" SessionModePending SessionMode = "pending" -) \ No newline at end of file +) diff --git a/jsonrpc.go b/jsonrpc.go index f7e4b0a4..423c71f6 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -56,7 +56,6 @@ type DisplayRotationSettings struct { Rotation string `json:"rotation"` } - type BacklightSettings struct { MaxBrightness int `json:"max_brightness"` DimAfter int `json:"dim_after"` @@ -1330,7 +1329,7 @@ func rpcRequestPrimary(sessionId string) map[string]interface{} { err := sessionManager.RequestPrimary(sessionId) if err != nil { return map[string]interface{}{ - "status": "error", + "status": "error", "message": err.Error(), } } @@ -1339,14 +1338,14 @@ func rpcRequestPrimary(sessionId string) map[string]interface{} { session := sessionManager.GetSession(sessionId) if session == nil { return map[string]interface{}{ - "status": "error", + "status": "error", "message": "session not found", } } return map[string]interface{}{ "status": "success", - "mode": string(session.Mode), + "mode": string(session.Mode), } } @@ -1358,7 +1357,6 @@ func rpcTransferPrimary(fromId string, toId string) error { return sessionManager.TransferPrimary(fromId, toId) } - func rpcGetSessionConfig() (map[string]interface{}, error) { maxSessions := 10 primaryTimeout := 300 @@ -1373,27 +1371,13 @@ func rpcGetSessionConfig() (map[string]interface{}, error) { } return map[string]interface{}{ - "enabled": true, - "maxSessions": maxSessions, - "primaryTimeout": primaryTimeout, + "enabled": true, + "maxSessions": maxSessions, + "primaryTimeout": primaryTimeout, "allowCloudOverride": true, }, nil } -func (s *Session) rpcApprovePrimaryRequest(requesterID string) error { - if s == nil || s.ID == "" { - return errors.New("invalid session") - } - return sessionManager.ApprovePrimaryRequest(s.ID, requesterID) -} - -func (s *Session) rpcDenyPrimaryRequest(requesterID string) error { - if s == nil || s.ID == "" { - return errors.New("invalid session") - } - return sessionManager.DenyPrimaryRequest(s.ID, requesterID) -} - var ( keyboardMacroCancel context.CancelFunc keyboardMacroLock sync.Mutex diff --git a/main.go b/main.go index dec37695..b59b83a4 100644 --- a/main.go +++ b/main.go @@ -19,12 +19,12 @@ func Main() { // Initialize currentSessionSettings to use config's persistent SessionSettings if config.SessionSettings == nil { config.SessionSettings = &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, PrivateKeystrokes: false, } - SaveConfig() + _ = SaveConfig() } currentSessionSettings = config.SessionSettings diff --git a/session_manager.go b/session_manager.go index 87ddf7d7..6dc388e0 100644 --- a/session_manager.go +++ b/session_manager.go @@ -58,28 +58,28 @@ type TransferBlacklistEntry struct { // Broadcast throttling to prevent DoS var ( - lastBroadcast time.Time - broadcastMutex sync.Mutex - broadcastDelay = 100 * time.Millisecond // Min time between broadcasts + lastBroadcast time.Time + broadcastMutex sync.Mutex + broadcastDelay = 100 * time.Millisecond // Min time between broadcasts // Pre-allocated event maps to reduce allocations - modePrimaryEvent = map[string]string{"mode": "primary"} - modeObserverEvent = map[string]string{"mode": "observer"} + modePrimaryEvent = map[string]string{"mode": "primary"} + modeObserverEvent = map[string]string{"mode": "observer"} ) type SessionManager struct { - mu sync.RWMutex // 24 bytes - place first for better alignment - primaryTimeout time.Duration // 8 bytes - logger *zerolog.Logger // 8 bytes - sessions map[string]*Session // 8 bytes - reconnectGrace map[string]time.Time // 8 bytes - reconnectInfo map[string]*SessionData // 8 bytes - transferBlacklist []TransferBlacklistEntry // Prevent demoted sessions from immediate re-promotion - queueOrder []string // 24 bytes (slice header) - primarySessionID string // 16 bytes - lastPrimaryID string // 16 bytes - maxSessions int // 8 bytes - cleanupCancel context.CancelFunc // For stopping cleanup goroutine + mu sync.RWMutex // 24 bytes - place first for better alignment + primaryTimeout time.Duration // 8 bytes + logger *zerolog.Logger // 8 bytes + sessions map[string]*Session // 8 bytes + reconnectGrace map[string]time.Time // 8 bytes + reconnectInfo map[string]*SessionData // 8 bytes + transferBlacklist []TransferBlacklistEntry // Prevent demoted sessions from immediate re-promotion + queueOrder []string // 24 bytes (slice header) + primarySessionID string // 16 bytes + lastPrimaryID string // 16 bytes + maxSessions int // 8 bytes + cleanupCancel context.CancelFunc // For stopping cleanup goroutine // Emergency promotion tracking for safety lastEmergencyPromotion time.Time @@ -107,14 +107,14 @@ func NewSessionManager(logger *zerolog.Logger) *SessionManager { } sm := &SessionManager{ - sessions: make(map[string]*Session), - reconnectGrace: make(map[string]time.Time), - reconnectInfo: make(map[string]*SessionData), + sessions: make(map[string]*Session), + reconnectGrace: make(map[string]time.Time), + reconnectInfo: make(map[string]*SessionData), transferBlacklist: make([]TransferBlacklistEntry, 0), - queueOrder: make([]string, 0), - logger: logger, - maxSessions: maxSessions, - primaryTimeout: primaryTimeout, + queueOrder: make([]string, 0), + logger: logger, + maxSessions: maxSessions, + primaryTimeout: primaryTimeout, } // Start background cleanup of inactive sessions @@ -200,8 +200,8 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe if sm.lastPrimaryID == session.ID && !isBlacklisted { // This is the rightful primary reconnecting within grace period sm.primarySessionID = session.ID - sm.lastPrimaryID = "" // Clear since primary successfully reconnected - delete(sm.reconnectGrace, session.ID) // Clear grace period + sm.lastPrimaryID = "" // Clear since primary successfully reconnected + delete(sm.reconnectGrace, session.ID) // Clear grace period sm.logger.Debug(). Str("sessionID", session.ID). Msg("Primary session successfully reconnected within grace period") @@ -290,7 +290,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe // we can always promote to primary when no primary exists session.Mode = SessionModePrimary sm.primarySessionID = session.ID - sm.lastPrimaryID = "" // Clear since we have a new primary + sm.lastPrimaryID = "" // Clear since we have a new primary // Clear all existing grace periods when a new primary is established // This prevents multiple sessions from fighting for primary status via grace period @@ -389,7 +389,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) { } // Limit grace period entries to prevent memory exhaustion (DoS protection) - const maxGraceEntries = 10 // Reduced from 20 to limit memory usage + const maxGraceEntries = 10 // Reduced from 20 to limit memory usage for len(sm.reconnectGrace) >= maxGraceEntries { // Find and remove the oldest grace period entry var oldestID string @@ -422,8 +422,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) { // If this was the primary session, clear primary slot and track for grace period if wasPrimary { - sm.lastPrimaryID = sessionID // Remember this was the primary for grace period - sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted + sm.lastPrimaryID = sessionID // Remember this was the primary for grace period + sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted sm.logger.Info(). Str("sessionID", sessionID). Dur("gracePeriod", time.Duration(gracePeriod)*time.Second). @@ -970,7 +970,7 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf toSession.Mode = SessionModePrimary toSession.hidRPCAvailable = false // Force re-handshake sm.primarySessionID = toSessionID - sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh + sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh // Clear input state sm.clearInputState() @@ -1038,9 +1038,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf // Send connection reset signal to the promoted session writeJSONRPCEvent("connectionModeChanged", map[string]interface{}{ "sessionId": toSessionID, - "newMode": string(toSession.Mode), - "reason": "session_promotion", - "action": "reconnect_required", + "newMode": string(toSession.Mode), + "reason": "session_promotion", + "action": "reconnect_required", "timestamp": time.Now().Unix(), }, toSession) @@ -1113,7 +1113,7 @@ func (sm *SessionManager) removeFromQueue(sessionID string) { func (sm *SessionManager) clearInputState() { // Clear keyboard state if gadget != nil { - gadget.KeyboardReport(0, []byte{0, 0, 0, 0, 0, 0}) + _ = gadget.KeyboardReport(0, []byte{0, 0, 0, 0, 0, 0}) } } @@ -1185,8 +1185,8 @@ func (sm *SessionManager) findMostTrustedSessionForEmergency() string { for sessionID, session := range sm.sessions { // Skip if blacklisted, primary, or not eligible modes if sm.isSessionBlacklisted(sessionID) || - session.Mode == SessionModePrimary || - (session.Mode != SessionModeObserver && session.Mode != SessionModeQueued) { + session.Mode == SessionModePrimary || + (session.Mode != SessionModeObserver && session.Mode != SessionModeQueued) { continue } @@ -1377,11 +1377,11 @@ func (sm *SessionManager) Shutdown() { } func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { - ticker := time.NewTicker(1 * time.Second) // Check every second for grace periods + ticker := time.NewTicker(1 * time.Second) // Check every second for grace periods defer ticker.Stop() - pendingTimeout := 1 * time.Minute // Reduced from 5 minutes to prevent DoS - validationCounter := 0 // Counter for periodic validateSinglePrimary calls + pendingTimeout := 1 * time.Minute // Reduced from 5 minutes to prevent DoS + validationCounter := 0 // Counter for periodic validateSinglePrimary calls for { select { @@ -1508,7 +1508,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { // Clean up pending sessions that have timed out (DoS protection) for id, session := range sm.sessions { if session.Mode == SessionModePending && - now.Sub(session.CreatedAt) > pendingTimeout { + now.Sub(session.CreatedAt) > pendingTimeout { websocketLogger.Info(). Str("sessionId", id). Dur("age", now.Sub(session.CreatedAt)). @@ -1549,8 +1549,8 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { bestScore := -1 for id, session := range sm.sessions { if id != timedOutSessionID && - !sm.isSessionBlacklisted(id) && - (session.Mode == SessionModeObserver || session.Mode == SessionModeQueued) { + !sm.isSessionBlacklisted(id) && + (session.Mode == SessionModeObserver || session.Mode == SessionModeQueued) { score := sm.getSessionTrustScore(id) if score > bestScore { bestScore = score @@ -1630,4 +1630,4 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { var sessionManager = NewSessionManager(websocketLogger) // Global session settings - references config.SessionSettings for persistence -var currentSessionSettings *SessionSettings \ No newline at end of file +var currentSessionSettings *SessionSettings diff --git a/session_permissions.go b/session_permissions.go index 35dcd43d..05a1bcbe 100644 --- a/session_permissions.go +++ b/session_permissions.go @@ -74,4 +74,4 @@ func RequirePermission(s *Session, perm Permission) error { return session.RequirePermissionForMode(s.Mode, perm) } return nil -} \ No newline at end of file +} diff --git a/ui/src/api/sessionApi.ts b/ui/src/api/sessionApi.ts index bbd93b6e..0cca4634 100644 --- a/ui/src/api/sessionApi.ts +++ b/ui/src/api/sessionApi.ts @@ -1,45 +1,52 @@ import { SessionInfo } from "@/stores/sessionStore"; +interface JsonRpcResponse { + result?: unknown; + error?: { message: string }; +} + +type RpcSendFunction = (method: string, params: Record, callback: (response: JsonRpcResponse) => void) => void; + export const sessionApi = { - getSessions: async (sendFn: Function): Promise => { + getSessions: async (sendFn: RpcSendFunction): Promise => { return new Promise((resolve, reject) => { - sendFn("getSessions", {}, (response: any) => { + sendFn("getSessions", {}, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { - resolve(response.result || []); + resolve((response.result as SessionInfo[]) || []); } }); }); }, - getSessionInfo: async (sendFn: Function, sessionId: string): Promise => { + getSessionInfo: async (sendFn: RpcSendFunction, sessionId: string): Promise => { return new Promise((resolve, reject) => { - sendFn("getSessionInfo", { sessionId }, (response: any) => { + sendFn("getSessionInfo", { sessionId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { - resolve(response.result); + resolve(response.result as SessionInfo); } }); }); }, - requestPrimary: async (sendFn: Function, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => { + requestPrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => { return new Promise((resolve, reject) => { - sendFn("requestPrimary", { sessionId }, (response: any) => { + sendFn("requestPrimary", { sessionId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { - resolve(response.result); + resolve(response.result as { status: string; mode?: string; message?: string }); } }); }); }, - releasePrimary: async (sendFn: Function, sessionId: string): Promise => { + releasePrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise => { return new Promise((resolve, reject) => { - sendFn("releasePrimary", { sessionId }, (response: any) => { + sendFn("releasePrimary", { sessionId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { @@ -50,12 +57,12 @@ export const sessionApi = { }, transferPrimary: async ( - sendFn: Function, + sendFn: RpcSendFunction, fromId: string, toId: string ): Promise => { return new Promise((resolve, reject) => { - sendFn("transferPrimary", { fromId, toId }, (response: any) => { + sendFn("transferPrimary", { fromId, toId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { @@ -66,12 +73,12 @@ export const sessionApi = { }, updateNickname: async ( - sendFn: Function, + sendFn: RpcSendFunction, sessionId: string, nickname: string ): Promise => { return new Promise((resolve, reject) => { - sendFn("updateSessionNickname", { sessionId, nickname }, (response: any) => { + sendFn("updateSessionNickname", { sessionId, nickname }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { @@ -82,11 +89,11 @@ export const sessionApi = { }, approveNewSession: async ( - sendFn: Function, + sendFn: RpcSendFunction, sessionId: string ): Promise => { return new Promise((resolve, reject) => { - sendFn("approveNewSession", { sessionId }, (response: any) => { + sendFn("approveNewSession", { sessionId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { @@ -97,11 +104,11 @@ export const sessionApi = { }, denyNewSession: async ( - sendFn: Function, + sendFn: RpcSendFunction, sessionId: string ): Promise => { return new Promise((resolve, reject) => { - sendFn("denyNewSession", { sessionId }, (response: any) => { + sendFn("denyNewSession", { sessionId }, (response: JsonRpcResponse) => { if (response.error) { reject(new Error(response.error.message)); } else { diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx index 7d0b6e46..484be9b0 100644 --- a/ui/src/components/AccessDeniedOverlay.tsx +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -1,13 +1,15 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useNavigate } from "react-router"; import { XCircleIcon } from "@heroicons/react/24/outline"; -import { Button } from "./Button"; + import { DEVICE_API, CLOUD_API } from "@/ui.config"; import { isOnDevice } from "@/main"; import { useUserStore } from "@/hooks/stores"; import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; import api from "@/api"; +import { Button } from "./Button"; + interface AccessDeniedOverlayProps { show: boolean; message?: string; @@ -25,7 +27,7 @@ export default function AccessDeniedOverlay({ const { clearNickname } = useSharedSessionStore(); const [countdown, setCountdown] = useState(10); - const handleLogout = async () => { + const handleLogout = useCallback(async () => { try { const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`; const res = await api.POST(logoutUrl); @@ -41,7 +43,7 @@ export default function AccessDeniedOverlay({ clearSession(); clearNickname(); navigate("/"); - }; + }, [navigate, setUser, clearSession, clearNickname]); useEffect(() => { if (!show) return; @@ -59,7 +61,7 @@ export default function AccessDeniedOverlay({ }, 1000); return () => clearInterval(timer); - }, [show]); + }, [show, handleLogout]); if (!show) return null; diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index c5d5e590..fc174e16 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -11,7 +11,7 @@ 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"; @@ -21,7 +21,6 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover"; import SessionPopover from "@/components/popovers/SessionPopover"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useSessionStore } from "@/stores/sessionStore"; -import { useRTCStore } from "@/hooks/stores"; import { usePermissions, Permission } from "@/hooks/usePermissions"; export default function Actionbar({ @@ -54,7 +53,7 @@ export default function Actionbar({ setSessions(response.result); rpcDataChannel.removeEventListener("message", handler); } - } catch (error) { + } catch { // Ignore parse errors for non-JSON messages } }; @@ -67,7 +66,7 @@ export default function Actionbar({ rpcDataChannel.removeEventListener("message", handler); }, 5000); } - }, [rpcDataChannel?.readyState]); + }, [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 diff --git a/ui/src/components/NicknameModal.tsx b/ui/src/components/NicknameModal.tsx index 3bfc232b..5b958621 100644 --- a/ui/src/components/NicknameModal.tsx +++ b/ui/src/components/NicknameModal.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useRef } from "react"; import { Dialog, DialogPanel, DialogBackdrop } from "@headlessui/react"; import { UserIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { Button } from "./Button"; -import { useSettingsStore } from "@/hooks/stores"; + +import { useSettingsStore , useRTCStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { useRTCStore } from "@/hooks/stores"; import { generateNickname } from "@/utils/nicknameGenerator"; +import { Button } from "./Button"; + type SessionRole = "primary" | "observer" | "queued" | "pending"; interface NicknameModalProps { @@ -128,8 +129,8 @@ export default function NicknameModal({ await onSubmit(trimmedNickname); setNickname(""); setGeneratedNickname(""); // Reset generated nickname after successful submit - } catch (error: any) { - setError(error.message || "Failed to set nickname"); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to set nickname"); setIsSubmitting(false); } }; diff --git a/ui/src/components/SessionControlPanel.tsx b/ui/src/components/SessionControlPanel.tsx index 2c29e841..3b923455 100644 --- a/ui/src/components/SessionControlPanel.tsx +++ b/ui/src/components/SessionControlPanel.tsx @@ -1,16 +1,19 @@ -import { useSessionStore } from "@/stores/sessionStore"; -import { sessionApi } from "@/api/sessionApi"; -import { Button } from "@/components/Button"; import { LockClosedIcon, LockOpenIcon, ClockIcon } from "@heroicons/react/16/solid"; import clsx from "clsx"; + +import { useSessionStore } from "@/stores/sessionStore"; +import { sessionApi } from "@/api/sessionApi"; +import { Button } from "@/components/Button"; import { usePermissions, Permission } from "@/hooks/usePermissions"; +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + interface SessionControlPanelProps { - sendFn: Function; + sendFn: RpcSendFunction; className?: string; } @@ -48,8 +51,8 @@ export default function SessionControlPanel({ sendFn, className }: SessionContro setSessionError(result.message || "Failed to request primary control"); setRequestingPrimary(false); } - } catch (error: any) { - setSessionError(error.message); + } catch (error) { + setSessionError(error instanceof Error ? error.message : "Unknown error"); console.error("Failed to request primary control:", error); setRequestingPrimary(false); } @@ -60,8 +63,8 @@ export default function SessionControlPanel({ sendFn, className }: SessionContro try { await sessionApi.releasePrimary(sendFn, currentSessionId); - } catch (error: any) { - setSessionError(error.message); + } catch (error) { + setSessionError(error instanceof Error ? error.message : "Unknown error"); console.error("Failed to release primary control:", error); } }; diff --git a/ui/src/components/SessionsList.tsx b/ui/src/components/SessionsList.tsx index bba58779..8d7c68f3 100644 --- a/ui/src/components/SessionsList.tsx +++ b/ui/src/components/SessionsList.tsx @@ -1,5 +1,6 @@ import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import clsx from "clsx"; + import { formatters } from "@/utils"; import { usePermissions, Permission } from "@/hooks/usePermissions"; diff --git a/ui/src/components/UnifiedSessionRequestDialog.tsx b/ui/src/components/UnifiedSessionRequestDialog.tsx index 1d1f1b6c..09b6a0cc 100644 --- a/ui/src/components/UnifiedSessionRequestDialog.tsx +++ b/ui/src/components/UnifiedSessionRequestDialog.tsx @@ -1,5 +1,6 @@ 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"; @@ -55,7 +56,8 @@ export default function UnifiedSessionRequestDialog({ return () => clearInterval(timer); } - }, [request?.id, request?.type]); // Only depend on stable properties + // 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(() => { diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 1187f44e..b344ee5b 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -14,7 +14,6 @@ import { useSettingsStore, useVideoStore, } from "@/hooks/stores"; -import { useSessionStore } from "@/stores/sessionStore"; import { usePermissions, Permission } from "@/hooks/usePermissions"; import useMouse from "@/hooks/useMouse"; @@ -37,7 +36,6 @@ export default function WebRTCVideo() { // Store hooks const settings = useSettingsStore(); - const { currentMode } = useSessionStore(); const { hasPermission } = usePermissions(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { @@ -230,7 +228,7 @@ export default function WebRTCVideo() { if (!hasPermission(Permission.MOUSE_INPUT)) return; handler(e); }; - }, [currentMode, getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight]); + }, [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight, hasPermission]); const relMouseMoveHandler = useMemo(() => { const handler = getRelMouseMoveHandler(); @@ -239,7 +237,7 @@ export default function WebRTCVideo() { if (!hasPermission(Permission.MOUSE_INPUT)) return; handler(e); }; - }, [currentMode, getRelMouseMoveHandler]); + }, [getRelMouseMoveHandler, hasPermission]); const mouseWheelHandler = useMemo(() => { const handler = getMouseWheelHandler(); @@ -248,7 +246,7 @@ export default function WebRTCVideo() { if (!hasPermission(Permission.MOUSE_INPUT)) return; handler(e); }; - }, [currentMode, getMouseWheelHandler]); + }, [getMouseWheelHandler, hasPermission]); const keyDownHandler = useCallback( (e: KeyboardEvent) => { @@ -288,7 +286,7 @@ export default function WebRTCVideo() { }, 100); } }, - [currentMode, handleKeyPress, isKeyboardLockActive], + [handleKeyPress, isKeyboardLockActive, hasPermission], ); const keyUpHandler = useCallback( @@ -310,7 +308,7 @@ export default function WebRTCVideo() { handleKeyPress(hidKey, false); }, - [currentMode, handleKeyPress], + [handleKeyPress, hasPermission], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { diff --git a/ui/src/components/popovers/SessionPopover.tsx b/ui/src/components/popovers/SessionPopover.tsx index a078b429..cf618eca 100644 --- a/ui/src/components/popovers/SessionPopover.tsx +++ b/ui/src/components/popovers/SessionPopover.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect } from "react"; -import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; +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"; @@ -29,11 +30,11 @@ export default function SessionPopover() { const { send } = useJsonRpc(); // Adapter function to match existing callback pattern - const sendRpc = (method: string, params: any, callback?: (response: any) => void) => { + const sendRpc = useCallback((method: string, params: Record, callback?: (response: { result?: unknown; error?: { message: string } }) => void) => { send(method, params, (response) => { if (callback) callback(response); }); - }; + }, [send]); const handleRefresh = async () => { if (isRefreshing) return; @@ -56,7 +57,7 @@ export default function SessionPopover() { .then(sessions => setSessions(sessions)) .catch(error => console.error("Failed to fetch sessions:", error)); } - }, []); + }, [sendRpc, sessions.length, setSessions]); return (
@@ -141,7 +142,7 @@ export default function SessionPopover() { setShowNicknameModal(true); }} onApprove={(sessionId) => { - sendRpc("approveNewSession", { sessionId }, (response: any) => { + sendRpc("approveNewSession", { sessionId }, (response) => { if (response.error) { console.error("Failed to approve session:", response.error); } else { @@ -150,7 +151,7 @@ export default function SessionPopover() { }); }} onDeny={(sessionId) => { - sendRpc("denyNewSession", { sessionId }, (response: any) => { + sendRpc("denyNewSession", { sessionId }, (response) => { if (response.error) { console.error("Failed to deny session:", response.error); } else { diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts index ca4fb5ff..3e3ed1e7 100644 --- a/ui/src/hooks/usePermissions.ts +++ b/ui/src/hooks/usePermissions.ts @@ -1,8 +1,11 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; + +import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc"; import { useSessionStore } from "@/stores/sessionStore"; import { useRTCStore } from "@/hooks/stores"; +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + // Permission types matching backend export enum Permission { // Video/Display permissions @@ -65,11 +68,11 @@ export function usePermissions() { const previousCanControl = useRef(false); // Function to poll permissions - const pollPermissions = useCallback((send: any) => { + const pollPermissions = useCallback((send: RpcSendFunction) => { if (!send) return; setIsLoading(true); - send("getPermissions", {}, (response: any) => { + send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => { if (!response.error && response.result) { const result = response.result as PermissionsResponse; setPermissions(result.permissions); @@ -79,13 +82,14 @@ export function usePermissions() { }, []); // Handle connectionModeChanged events that require WebRTC reconnection - const handleRpcRequest = useCallback((request: any) => { + const handleRpcRequest = useCallback((request: JsonRpcRequest) => { if (request.method === "connectionModeChanged") { console.info("Connection mode changed, WebRTC reconnection required", request.params); // For session promotion that requires reconnection, refresh the page // This ensures WebRTC connection is re-established with proper mode - if (request.params?.action === "reconnect_required" && request.params?.reason === "session_promotion") { + const params = request.params as { action?: string; reason?: string }; + if (params.action === "reconnect_required" && params.reason === "session_promotion") { console.info("Session promoted, refreshing page to re-establish WebRTC connection"); // Small delay to ensure all state updates are processed setTimeout(() => { @@ -132,7 +136,8 @@ export function usePermissions() { } previousCanControl.current = currentCanControl; - }, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); // hasPermission depends on permissions which is already in deps const hasPermission = (permission: Permission): boolean => { return permissions[permission] === true; diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts index 393ff1c8..b5c6804a 100644 --- a/ui/src/hooks/useSessionEvents.ts +++ b/ui/src/hooks/useSessionEvents.ts @@ -1,11 +1,14 @@ import { useEffect, useRef } from "react"; -import { useSessionStore } from "@/stores/sessionStore"; + +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, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + interface SessionEventData { - sessions: any[]; + sessions: SessionInfo[]; yourMode: string; } @@ -13,7 +16,7 @@ interface ModeChangedData { mode: string; } -export function useSessionEvents(sendFn: Function | null) { +export function useSessionEvents(sendFn: RpcSendFunction | null) { const { currentMode, setSessions, @@ -25,7 +28,7 @@ export function useSessionEvents(sendFn: Function | null) { sendFnRef.current = sendFn; // Handle session-related RPC events - const handleSessionEvent = (method: string, params: any) => { + const handleSessionEvent = (method: string, params: unknown) => { switch (method) { case "sessionsUpdated": handleSessionsUpdated(params as SessionEventData); @@ -52,7 +55,7 @@ export function useSessionEvents(sendFn: Function | null) { // CRITICAL: Only update mode, never show notifications from sessionsUpdated // Notifications are exclusively handled by handleModeChanged to prevent duplicates if (data.yourMode && data.yourMode !== currentMode) { - updateSessionMode(data.yourMode as any); + updateSessionMode(data.yourMode as "primary" | "observer" | "queued" | "pending"); } }; @@ -64,7 +67,7 @@ export function useSessionEvents(sendFn: Function | null) { // Get the most current mode from the store to avoid race conditions const { currentMode: currentModeFromStore } = useSessionStore.getState(); const previousMode = currentModeFromStore; - updateSessionMode(data.mode as any); + updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending"); // Clear requesting state when mode changes from queued if (previousMode === "queued" && data.mode !== "queued") { @@ -139,7 +142,7 @@ export function useSessionEvents(sendFn: Function | null) { try { const sessions = await sessionApi.getSessions(sendFnRef.current); setSessions(sessions); - } catch (error) { + } catch { // Silently fail on refresh errors } }, 30000); // Refresh every 30 seconds diff --git a/ui/src/hooks/useSessionManagement.ts b/ui/src/hooks/useSessionManagement.ts index 42078412..4b688368 100644 --- a/ui/src/hooks/useSessionManagement.ts +++ b/ui/src/hooks/useSessionManagement.ts @@ -1,9 +1,12 @@ import { useEffect, useCallback, useState } from "react"; + import { useSessionStore } from "@/stores/sessionStore"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { useSettingsStore } from "@/hooks/stores"; import { usePermissions, Permission } from "@/hooks/usePermissions"; +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + interface SessionResponse { sessionId?: string; mode?: string; @@ -23,7 +26,7 @@ interface NewSessionRequest { nickname?: string; } -export function useSessionManagement(sendFn: Function | null) { +export function useSessionManagement(sendFn: RpcSendFunction | null) { const { setCurrentSession, clearSession @@ -39,7 +42,7 @@ export function useSessionManagement(sendFn: Function | null) { // Handle session info from WebRTC answer const handleSessionResponse = useCallback((response: SessionResponse) => { if (response.sessionId && response.mode) { - setCurrentSession(response.sessionId, response.mode as any); + setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending"); } }, [setCurrentSession]); @@ -48,7 +51,7 @@ export function useSessionManagement(sendFn: Function | null) { if (!sendFn) return; return new Promise((resolve, reject) => { - sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: any) => { + 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")); @@ -65,7 +68,7 @@ export function useSessionManagement(sendFn: Function | null) { if (!sendFn) return; return new Promise((resolve, reject) => { - sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: any) => { + 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")); @@ -82,7 +85,7 @@ export function useSessionManagement(sendFn: Function | null) { if (!sendFn) return; return new Promise((resolve, reject) => { - sendFn("approveNewSession", { sessionId }, (response: any) => { + 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")); @@ -99,7 +102,7 @@ export function useSessionManagement(sendFn: Function | null) { if (!sendFn) return; return new Promise((resolve, reject) => { - sendFn("denyNewSession", { sessionId }, (response: any) => { + 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")); @@ -112,7 +115,7 @@ export function useSessionManagement(sendFn: Function | null) { }, [sendFn]); // Handle RPC events - const handleRpcEvent = useCallback((method: string, params: any) => { + const handleRpcEvent = useCallback((method: string, params: unknown) => { // Pass session events to the session event handler if (method === "sessionsUpdated" || method === "modeChanged" || @@ -122,12 +125,12 @@ export function useSessionManagement(sendFn: Function | null) { // Handle new session approval request (only if approval is required and user has permission) if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) { - setNewSessionRequest(params); + setNewSessionRequest(params as NewSessionRequest); } // Handle primary control request if (method === "primaryControlRequested") { - setPrimaryControlRequest(params); + setPrimaryControlRequest(params as PrimaryControlRequest); } // Handle approval/denial responses @@ -147,13 +150,14 @@ export function useSessionManagement(sendFn: Function | null) { // Handle session access denial (when your new session is denied) if (method === "sessionAccessDenied") { const { clearSession, setSessionError } = useSessionStore.getState(); - setSessionError(params.message || "Session access was denied by the primary session"); + const errorParams = params as { message?: string }; + setSessionError(errorParams.message || "Session access was denied by the primary session"); // Clear session data as we're being disconnected setTimeout(() => { clearSession(); }, 3000); // Give user time to see the error } - }, [handleSessionEvent]); + }, [handleSessionEvent, hasPermission, requireSessionApproval]); // Cleanup on unmount useEffect(() => { diff --git a/ui/src/routes/devices.$id.settings.multi-session.tsx b/ui/src/routes/devices.$id.settings.multi-session.tsx index fab886e1..1c6970e6 100644 --- a/ui/src/routes/devices.$id.settings.multi-session.tsx +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -1,4 +1,8 @@ import { useEffect, useState } from "react"; +import { + UserGroupIcon, +} from "@heroicons/react/16/solid"; + import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; import { usePermissions, Permission } from "@/hooks/usePermissions"; import { useSettingsStore } from "@/hooks/stores"; @@ -7,9 +11,6 @@ import Card from "@/components/Card"; import Checkbox from "@/components/Checkbox"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsItem } from "@/components/SettingsItem"; -import { - UserGroupIcon, -} from "@heroicons/react/16/solid"; export default function SessionsSettings() { const { send } = useJsonRpc(); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 23bc4c2f..a9a4782f 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -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, @@ -15,7 +15,6 @@ import { LuUsers, } from "react-icons/lu"; import { useResizeObserver } from "usehooks-ts"; -import { useNavigate } from "react-router"; import { cx } from "@/cva.config"; import Card from "@components/Card"; diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a90b5cb6..196c5a62 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -230,7 +230,6 @@ export default function KvmIdRoute() { ); cleanupAndStopReconnecting(); clearInterval(checkInterval); - } else { } }, 1000); }, @@ -270,6 +269,7 @@ export default function KvmIdRoute() { // We don't want to close everything down, we wait for the reconnect to stop instead }, onOpen() { + // Connection established, message handling will begin }, onMessage: message => { @@ -572,7 +572,7 @@ export default function KvmIdRoute() { setRequireSessionNickname(response.result.requireNickname); } } - } catch (error) { + } catch { // Ignore parse errors } }; @@ -627,6 +627,9 @@ export default function KvmIdRoute() { setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableChannel, setTransceiver, + hasPermission, + setRequireSessionApproval, + setRequireSessionNickname, ]); useEffect(() => { diff --git a/ui/src/utils/nicknameGenerator.ts b/ui/src/utils/nicknameGenerator.ts index 3ce270fa..76c09fab 100644 --- a/ui/src/utils/nicknameGenerator.ts +++ b/ui/src/utils/nicknameGenerator.ts @@ -1,7 +1,9 @@ // Nickname generation using backend API for consistency +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + // Main function that uses backend generation -export async function generateNickname(sendFn?: Function): Promise { +export async function generateNickname(sendFn?: RpcSendFunction): Promise { // Require backend function - no fallback if (!sendFn) { throw new Error('Backend connection required for nickname generation'); @@ -9,9 +11,10 @@ export async function generateNickname(sendFn?: Function): Promise { return new Promise((resolve, reject) => { try { - const result = sendFn('generateNickname', { userAgent: navigator.userAgent }, (response: any) => { - if (response && !response.error && response.result?.nickname) { - resolve(response.result.nickname); + 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')); } diff --git a/web.go b/web.go index 55a6d992..1c66ec6d 100644 --- a/web.go +++ b/web.go @@ -34,22 +34,22 @@ import ( 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 + 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 + RequireApproval bool `json:"requireApproval"` + RequireNickname bool `json:"requireNickname"` + ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection + PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session + Nickname string `json:"nickname,omitempty"` + PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events } type SetPasswordRequest struct { @@ -483,7 +483,7 @@ func handleLogout(c *gin.Context) { // Only clear the cookies for this session, don't invalidate the token // The token should remain valid for other sessions c.SetCookie("authToken", "", -1, "/", "", false, true) - c.SetCookie("sessionId", "", -1, "/", "", false, true) // Clear session ID cookie too + c.SetCookie("sessionId", "", -1, "/", "", false, true) // Clear session ID cookie too c.JSON(http.StatusOK, gin.H{"message": "Logout successful"}) } diff --git a/webrtc.go b/webrtc.go index 8e6121bd..78e5eb47 100644 --- a/webrtc.go +++ b/webrtc.go @@ -21,24 +21,24 @@ import ( // Predefined browser string constants for memory efficiency var ( - BrowserChrome = "chrome" - BrowserFirefox = "firefox" - BrowserSafari = "safari" - BrowserEdge = "edge" - BrowserOpera = "opera" - BrowserUnknown = "user" + 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 + 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 @@ -105,7 +105,7 @@ type SessionConfig struct { ICEServers []string LocalIP string IsCloud bool - UserAgent string // User agent for browser detection and nickname generation + UserAgent string // User agent for browser detection and nickname generation ws *websocket.Conn Logger *zerolog.Logger } @@ -278,7 +278,7 @@ func newSession(config SessionConfig) (*Session, error) { session := &Session{ peerConnection: peerConnection, - Browser: extractBrowserFromUserAgent(config.UserAgent), + Browser: extractBrowserFromUserAgent(config.UserAgent), } session.rpcQueue = make(chan webrtc.DataChannelMessage, 256) session.initQueues() From b0494e8eefa524ce89cebae9f85556183d1c12b8 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 20:24:10 +0300 Subject: [PATCH 3/7] 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 --- deploy.log | 999 -------------------------------- internal/session/permissions.go | 4 +- 2 files changed, 2 insertions(+), 1001 deletions(-) delete mode 100644 deploy.log diff --git a/deploy.log b/deploy.log deleted file mode 100644 index 31640d15..00000000 --- a/deploy.log +++ /dev/null @@ -1,999 +0,0 @@ -▶ Building frontend(B - -added 429 packages, and audited 430 packages in 16s - -142 packages are looking for funding - run `npm fund` for details - -found 0 vulnerabilities - -> kvm-ui@2025.10.01.1900 build:device -> tsc && vite build --mode=device --emptyOutDir - -vite v7.1.7 building for device... -transforming... -✓ 2995 modules transformed. -rendering chunks... -20:14:45 info [plugin vite:reporter] -20:14:45 info (!) /workspaces/kvm/ui/src/hooks/hidRpc.ts is dynamically imported by /workspaces/kvm/ui/src/hooks/usePermissions.ts but also statically imported by /workspaces/kvm/ui/src/hooks/useHidRpc.ts, /workspaces/kvm/ui/src/hooks/useKeyboard.ts, dynamic import will not move module into another chunk. -20:14:45 info  -computing gzip size... -../static/index.html 2.84 kB │ gzip: 0.95 kB -../static/assets/immutable/netboot-icon-OoGRDuxL.svg 10.60 kB │ gzip: 4.51 kB -../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png 13.38 kB -../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2 68.28 kB -../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2 69.73 kB -../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2 69.82 kB -../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2 70.32 kB -../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2 70.87 kB -../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2 70.92 kB -../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2 71.26 kB -../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2 72.14 kB -../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2 72.99 kB -../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2 73.33 kB -../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2 73.63 kB -../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2 73.63 kB -../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2 73.76 kB -../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2 74.18 kB -../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2 75.12 kB -../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2 76.17 kB -../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png 188.53 kB -../static/assets/immutable/vendor-CCFZcsOx.css 3.55 kB │ gzip: 1.08 kB -../static/assets/immutable/index-3KaWki3G.css 108.89 kB │ gzip: 16.78 kB -../static/assets/immutable/AutoHeight-DNMbSxBi.js 0.40 kB │ gzip: 0.30 kB -../static/assets/immutable/FeatureFlag-CpBy_yIc.js 0.56 kB │ gzip: 0.39 kB -../static/assets/immutable/login-jvmJO2K9.js 0.65 kB │ gzip: 0.40 kB -../static/assets/immutable/signup-Dcu7CrZF.js 0.68 kB │ gzip: 0.40 kB -../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js 0.86 kB │ gzip: 0.57 kB -../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js 0.94 kB │ gzip: 0.53 kB -../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js 1.04 kB │ gzip: 0.54 kB -../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js 1.09 kB │ gzip: 0.56 kB -../static/assets/immutable/devices._id.other-session-DOmnPqBd.js 1.12 kB │ gzip: 0.57 kB -../static/assets/immutable/devices.already-adopted-CUKof-fH.js 1.20 kB │ gzip: 0.58 kB -../static/assets/immutable/Checkbox-DfeYpuQt.js 1.27 kB │ gzip: 0.66 kB -../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js 1.66 kB │ gzip: 0.83 kB -../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js 1.74 kB │ gzip: 0.77 kB -../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js 1.80 kB │ gzip: 0.81 kB -../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js 1.95 kB │ gzip: 1.01 kB -../static/assets/immutable/Terminal-DrfJbtdJ.js 3.18 kB │ gzip: 1.63 kB -../static/assets/immutable/AuthLayout-BU_zUyj5.js 3.85 kB │ gzip: 1.73 kB -../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js 5.81 kB │ gzip: 1.85 kB -../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js 5.98 kB │ gzip: 2.12 kB -../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js 6.01 kB │ gzip: 2.09 kB -../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js 6.17 kB │ gzip: 1.54 kB -../static/assets/immutable/devices._id.settings.video-w6XP12DM.js 6.88 kB │ gzip: 2.60 kB -../static/assets/immutable/devices._id.settings-DQ5hSmGr.js 8.77 kB │ gzip: 1.63 kB -../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js 8.77 kB │ gzip: 3.05 kB -../static/assets/immutable/connectionStats-BTh-HMMJ.js 8.92 kB │ gzip: 3.57 kB -../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js 9.70 kB │ gzip: 2.49 kB -../static/assets/immutable/MacroForm-DlAcnh76.js 10.00 kB │ gzip: 3.55 kB -../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js 10.82 kB │ gzip: 3.17 kB -../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js 15.47 kB │ gzip: 3.30 kB -../static/assets/immutable/devices._id.mount-eRr7fRnt.js 32.30 kB │ gzip: 14.11 kB -../static/assets/immutable/index-ITvbnH2t.js 249.49 kB │ gzip: 64.18 kB -../static/assets/immutable/vendor-C8gpwofI.js 1,568.17 kB │ gzip: 469.07 kB -20:14:46 info  -20:14:46 info (!) Some chunks are larger than 500 kB after minification. Consider: -20:14:46 info - Using dynamic import() to code-split the application -20:14:46 info - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks -20:14:46 info - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. -✓ built in 9.00s -20:14:46 info ../static/favicon.ico: 92.7% -- created ../static/favicon.ico.gz -20:14:46 info ../static/index.html: 67.0% -- created ../static/index.html.gz -20:14:46 info ../static/web-app-manifest-192x192.png: -0.3% -- created ../static/web-app-manifest-192x192.png.gz -20:14:46 info ../static/apple-touch-icon.png: -0.3% -- created ../static/apple-touch-icon.png.gz -20:14:46 info ../static/favicon.png: -0.4% -- created ../static/favicon.png.gz -20:14:46 info ../static/web-app-manifest-512x512.png: 5.1% -- created ../static/web-app-manifest-512x512.png.gz -20:14:46 info ../static/favicon-96x96.png: 0.0% -- created ../static/favicon-96x96.png.gz -20:14:46 info ../static/sse.html: 77.3% -- created ../static/sse.html.gz -20:14:46 info ../static/fonts/CircularXXWeb-Regular.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Regular.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-ExtraBlackItalic.woff2: 0.0% -- created ../static/fonts/CircularXXWeb-ExtraBlackItalic.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-BookItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-BookItalic.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-Black.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Black.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-Medium.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Medium.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-BlackItalic.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-BlackItalic.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-Book.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Book.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-Light.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Light.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-Bold.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Bold.woff2.gz -20:14:46 info ../static/fonts/CircularXXWeb-MediumItalic.woff2: 0.0% -- created ../static/fonts/CircularXXWeb-MediumItalic.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-Thin.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-Thin.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-LightItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-LightItalic.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-Italic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-Italic.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-BoldItalic.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-BoldItalic.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-ExtraBlack.woff2: 0.1% -- created ../static/fonts/CircularXXWeb-ExtraBlack.woff2.gz -20:14:47 info ../static/fonts/CircularXXWeb-ThinItalic.woff2: 0.2% -- created ../static/fonts/CircularXXWeb-ThinItalic.woff2.gz -20:14:47 info ../static/fonts/fonts.css: 89.4% -- created ../static/fonts/fonts.css.gz -20:14:47 info ../static/assets/immutable/devices._id.settings.video-w6XP12DM.js: 62.8% -- created ../static/assets/immutable/devices._id.settings.video-w6XP12DM.js.gz -20:14:47 info ../static/assets/immutable/vendor-C8gpwofI.js: 70.3% -- created ../static/assets/immutable/vendor-C8gpwofI.js.gz -20:14:47 info ../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Medium-B7cMW5Np.woff2.gz -20:14:47 info ../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js: 68.4% -- created ../static/assets/immutable/devices._id.settings.multi-session-BAaxVISr.js.gz -20:14:47 info ../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Italic-D9ZGG85N.woff2.gz -20:14:47 info ../static/assets/immutable/index-ITvbnH2t.js: 74.6% -- created ../static/assets/immutable/index-ITvbnH2t.js.gz -20:14:47 info ../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png: 0.6% -- created ../static/assets/immutable/jetkvm-device-still-ktpTGyfA.png.gz -20:14:47 info ../static/assets/immutable/AutoHeight-DNMbSxBi.js: 29.0% -- created ../static/assets/immutable/AutoHeight-DNMbSxBi.js.gz -20:14:47 info ../static/assets/immutable/AuthLayout-BU_zUyj5.js: 55.6% -- created ../static/assets/immutable/AuthLayout-BU_zUyj5.js.gz -20:14:47 info ../static/assets/immutable/MacroForm-DlAcnh76.js: 64.8% -- created ../static/assets/immutable/MacroForm-DlAcnh76.js.gz -20:14:47 info ../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js: 78.8% -- created ../static/assets/immutable/devices._id.settings.network-PcgdKo-I.js.gz -20:14:47 info ../static/assets/immutable/index-3KaWki3G.css: 84.9% -- created ../static/assets/immutable/index-3KaWki3G.css.gz -20:14:47 info ../static/assets/immutable/FeatureFlag-CpBy_yIc.js: 34.8% -- created ../static/assets/immutable/FeatureFlag-CpBy_yIc.js.gz -20:14:47 info ../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Bold-6tPVDRzI.woff2.gz -20:14:47 info ../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js: 36.3% -- created ../static/assets/immutable/devices._id.settings.macros.add-DtgJATY5.js.gz -20:14:47 info ../static/assets/immutable/login-jvmJO2K9.js: 41.1% -- created ../static/assets/immutable/login-jvmJO2K9.js.gz -20:14:47 info ../static/assets/immutable/devices.already-adopted-CUKof-fH.js: 52.5% -- created ../static/assets/immutable/devices.already-adopted-CUKof-fH.js.gz -20:14:47 info ../static/assets/immutable/devices._id.mount-eRr7fRnt.js: 56.6% -- created ../static/assets/immutable/devices._id.mount-eRr7fRnt.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Regular-C2Km8yIq.woff2.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js: 49.8% -- created ../static/assets/immutable/devices._id.settings.general.reboot-ZolNQeG-.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-LightItalic-DkMJsSQn.woff2.gz -20:14:48 info ../static/assets/immutable/signup-Dcu7CrZF.js: 43.2% -- created ../static/assets/immutable/signup-Dcu7CrZF.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js: 48.8% -- created ../static/assets/immutable/devices._id.settings.macros.edit-CKsO8CQU.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js: 65.7% -- created ../static/assets/immutable/devices._id.settings.mouse-J-WjFYto.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Light-COmyZsa9.woff2.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js: 65.6% -- created ../static/assets/immutable/devices._id.settings.advanced-BKtcXLuC.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-BlackItalic-2aNe932P.woff2.gz -20:14:48 info ../static/assets/immutable/Terminal-DrfJbtdJ.js: 49.2% -- created ../static/assets/immutable/Terminal-DrfJbtdJ.js.gz -20:14:48 info ../static/assets/immutable/devices._id.other-session-DOmnPqBd.js: 50.2% -- created ../static/assets/immutable/devices._id.other-session-DOmnPqBd.js.gz -20:14:48 info ../static/assets/immutable/Checkbox-DfeYpuQt.js: 49.7% -- created ../static/assets/immutable/Checkbox-DfeYpuQt.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js: 70.8% -- created ../static/assets/immutable/devices._id.settings.hardware-BJuy5KhB.js.gz -20:14:48 info ../static/assets/immutable/connectionStats-BTh-HMMJ.js: 60.5% -- created ../static/assets/immutable/connectionStats-BTh-HMMJ.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js: 50.8% -- created ../static/assets/immutable/devices._id.settings.keyboard-Brhk8Q16.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-ThinItalic-BbeKWZX4.woff2.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2: 0.0% -- created ../static/assets/immutable/CircularXXWeb-ExtraBlackItalic-B8bd8esZ.woff2.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2: 0.0% -- created ../static/assets/immutable/CircularXXWeb-MediumItalic-Cw0wlEIE.woff2.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-BoldItalic-BY-z05Z9.woff2.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js: 56.6% -- created ../static/assets/immutable/devices._id.settings.general._index-Cojn7PdF.js.gz -20:14:48 info ../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Black-CPoU8bYr.woff2.gz -20:14:48 info ../static/assets/immutable/devices._id.settings-DQ5hSmGr.js: 81.6% -- created ../static/assets/immutable/devices._id.settings-DQ5hSmGr.js.gz -20:14:48 info ../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js: 56.3% -- created ../static/assets/immutable/ConfirmDialog-ZPsIbN8O.js.gz -20:14:48 info ../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js: 50.6% -- created ../static/assets/immutable/UpdateInProgressStatusCard-CxrffonE.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js: 65.0% -- created ../static/assets/immutable/devices._id.settings.macros-CSWrsivS.js.gz -20:14:48 info ../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js: 75.3% -- created ../static/assets/immutable/devices._id.settings.access.local-auth-AMVTxQRp.js.gz -20:14:49 info ../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js: 74.6% -- created ../static/assets/immutable/devices._id.settings.general.update-DSe-tEcb.js.gz -20:14:49 info ../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-ExtraBlack-zwQ9rYrv.woff2.gz -20:14:49 info ../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2: 0.2% -- created ../static/assets/immutable/CircularXXWeb-Thin-CgvRHm5r.woff2.gz -20:14:49 info ../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js: 44.7% -- created ../static/assets/immutable/devices._id.settings.appearance-D-zQ8eOU.js.gz -20:14:49 info ../static/assets/immutable/netboot-icon-OoGRDuxL.svg: 56.5% -- created ../static/assets/immutable/netboot-icon-OoGRDuxL.svg.gz -20:14:49 info ../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-BookItalic-Dot97ozQ.woff2.gz -20:14:49 info ../static/assets/immutable/vendor-CCFZcsOx.css: 70.1% -- created ../static/assets/immutable/vendor-CCFZcsOx.css.gz -20:14:49 info ../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2: 0.1% -- created ../static/assets/immutable/CircularXXWeb-Book-DcdztGze.woff2.gz -20:14:49 info ../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png: 12.6% -- created ../static/assets/immutable/keyboard-and-mouse-connected-CIPI4-KS.png.gz -20:14:49 info ../static/jetkvm.svg: 72.2% -- created ../static/jetkvm.svg.gz -20:14:49 info ../static/favicon.svg: 70.1% -- created ../static/favicon.svg.gz -▶ Building release binary(B -▶ Building the project in host ...(B -20:14:49 info + make build_release SKIP_NATIVE_IF_EXISTS=0 SKIP_UI_BUILD=1 -Skipping frontend build... -Building native... -▶ Generating UI index(B -ui_index.c has been generated successfully. -▶ Building native library(B -Re-run cmake no build system arguments --- Using defconfig: /workspaces/kvm/internal/native/cgo/lvgl_defconfig --- Converted to absolute path: /workspaces/kvm/internal/native/cgo/lvgl_defconfig -['/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/scripts/kconfig.py', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/Kconfig', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/.config', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/autoconf.h', '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/kconfig_list', '/workspaces/kvm/internal/native/cgo/lvgl_defconfig'] -Parsing /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/Kconfig -Loaded configuration '/workspaces/kvm/internal/native/cgo/lvgl_defconfig' -No change to configuration in '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-src/.config' -No change to Kconfig header in '/workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/autoconf.h' -Failed to locate pcpp - installing it -PCPP is already installed in venv -Preprocessing completed. Output saved to /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/tmp.h -Expanded configuration header saved to /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/lv_conf_expanded.h -Temporary preprocessed file /workspaces/kvm/internal/native/cgo/build/_deps/lvgl-build/tmp.h removed. --- Enabling the building of ThorVG internal --- Configuring done (3.8s) --- Generating done (0.3s) --- Build files have been written to: /workspaces/kvm/internal/native/cgo/build -▶ Copying built library and header files(B -gmake[1]: Entering directory '/workspaces/kvm/internal/native/cgo/build' -20:14:55 info gmake[1]: Warning: File 'Makefile' has modification time 0.11 s in the future -20:14:56 info gmake[3]: Warning: File '_deps/lvgl-build/CMakeFiles/lvgl_thorvg.dir/compiler_depend.make' has modification time 0.32 s in the future -20:14:56 info gmake[3]: warning: Clock skew detected. Your build may be incomplete. -[ 7%] Built target lvgl_thorvg -[ 51%] Built target lvgl -[ 52%] Building C object CMakeFiles/jknative.dir/ui_index.c.o -[ 52%] Linking CXX static library libjknative.a -[100%] Built target jknative -Install the project... --- Install configuration: "Release" --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_templ.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_async.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_timer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_rb.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_math.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_image_header_cache.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_image_cache.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance/lv_cache_instance.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_class.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_lru_ll.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class/lv_cache_lru_rb.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_entry.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler_builtin.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_array.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_bidi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_grad.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_palette.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_assert.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_types.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_event.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style_gen.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_iter.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim_timeline.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text_ap.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_circle_buf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_fs.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color_op.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_area.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_log.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_lru.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_matrix.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_ll.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_tree.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx/lv_qnx.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_display.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_gnu_efi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_edk2.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_std_wrapper.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_indev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_context.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev/lv_evdev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_xkb.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_libinput.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/lv_drivers.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/wayland --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_keyboard.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_window.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_mousewheel.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_mouse.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11/lv_x11.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796/lv_st7796.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm/lv_linux_drm.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb/lv_linux_fbdev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc/lv_renesas_glcdc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735/lv_st7735.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x/lv_ft81x_defines.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x/lv_ft81x.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd/lv_lcd_generic_mipi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc/lv_st_ltdc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341/lv_ili9341.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi/lv_tft_espi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789/lv_st7789.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/glfw --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/windows --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_entry.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_profiler.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_cache.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_image_cache.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_lcd.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_libuv.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_fbdev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx/lv_nuttx_touchscreen.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_group.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_event.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_property.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style_gen.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_pos.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_scroll.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_tree.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_class.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_refr.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_global.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_draw.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v9_1.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_init.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v8.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_cmsis_rtos2.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_pthread.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_mqx.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_sdl2.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_windows.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_rtthread.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_freertos.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os_none.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_conf_kconfig.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot/lv_snapshot.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime/lv_ime_pinyin.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg/vg_lite.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_display.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_indev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_indev_gesture.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_helpers.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_screenshot_compare.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer/lv_file_explorer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer/lv_observer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_widget.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_checkbox_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_dropdown_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_keyboard_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_textarea_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_canvas_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_chart_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_slider_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_spangroup_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_tabview_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_obj_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_calendar_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_roller_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_table_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_label_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_image_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_event_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_buttonmatrix_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_button_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_scale_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_arc_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers/lv_xml_bar_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_base_types.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_component.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_update.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_style.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment/lv_fragment.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav/lv_gridnav.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey/lv_monkey.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon/lv_sysmon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont/lv_imgfont.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager/lv_font_manager.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager/lv_font_manager_recycle.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_parser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_decoder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_token.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg/lv_svg_render.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgUtil.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieProperty.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieInterpolator.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgFill.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieModifier.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieModel.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgArray.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLoadModule.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgLoaderCommon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgPicture.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/config.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLoader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCommon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgText.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterAvx.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieParserHandler.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg_capi.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgScene.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterTexmap.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgRender.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLock.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieParser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgPaint.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieBuilder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgStr.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCanvas.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterC.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgTaskScheduler.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieExpressions.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieLoader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgIteratorAccessor.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieCommon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgPath.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgInlist.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgSceneBuilder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgLoader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgMath.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRenderer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgRawLoader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/fwd.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/memorystream.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/reader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/prettywriter.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/ostreamwrapper.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/encodedstream.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/filereadstream.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/cursorstreamwrapper.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/istreamwrapper.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/uri.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/stringbuffer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/ieee754.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/strtod.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/swap.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/regex.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/diyfp.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/biginteger.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/strfunc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/itoa.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/stack.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/dtoa.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/clzll.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/meta.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal/pow10.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/encodings.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/schema.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/stream.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/filewritestream.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/rapidjson.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/document.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/allocators.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/writer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error/error.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error/en.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/memorybuffer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/pointer.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes/inttypes.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes/stdint.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgShape.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSaveModule.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgBinaryDesc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/thorvg_lottie.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwRasterNeon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgAnimation.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSvgCssStyle.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgLottieRenderPooler.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgXmlParser.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgFrameModule.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgCompressor.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/tvgSwCommon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo/lv_libjpeg_turbo.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg/lv_ffmpeg.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng/lv_libpng.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/tjpgd.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/tjpgdcnf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd/lv_tjpgd.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/stb_truetype_htcw.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/stb_rect_pack.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf/lv_tiny_ttf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/ftoption.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/lv_freetype.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/ftmodule.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat_config.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/internal.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmltok.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/nametab.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/siphash.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/latin1tab.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat_external.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/winconfig.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmltok_impl.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/utf8tab.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/ascii.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/xmlrole.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/expat.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/asciitab.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat/iasciitab.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4 --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4/lz4.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/lv_qrcode.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/qrcodegen.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle/lv_rle.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng/lodepng.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng/lv_lodepng.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder/lv_bin_decoder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie/lv_rlottie.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv/lv_fsdrv.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp/lv_bmp.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/code128.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/lv_barcode.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/gifdec_mve.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/lv_gif.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/gifdec.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_api_map_v9_0.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lv_conf_internal.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/lv_layout.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex/lv_flex.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid/lv_grid.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_sprintf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/uefi --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/clib --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/micropython --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_string.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_mem.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/rtthread --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin/lv_tlsf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_gesture.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_scroll.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display/lv_display.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_binfont_loader.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font_fmt_txt.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_symbol_def.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lvgl.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default/lv_theme_default.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple/lv_theme_simple.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/lv_theme.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono/lv_theme_mono.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_grad.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_mask.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_l8.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium/lv_blend_helium.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_al88.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb888.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565_swapped.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d/lv_blend_arm2d.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888_premultiplied.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon/lv_blend_neon.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d/lv_draw_sw_arm2d.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d/lv_draw_sw_helium.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_decoder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_g2d_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_g2d_buf_map.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d/lv_draw_g2d.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_cfg.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_draw_pxp.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp/lv_pxp_osa.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_matrix.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_draw_vglite.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_buf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite/lv_vglite_path.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_label.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_mask.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_dsc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_triangle.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_vector.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_3d.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_line.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl/lv_draw_sdl.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_draw_vg_lite.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_path.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_draw_vg_lite_type.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_utils.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_math.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_stroke.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_decoder.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_pending.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite/lv_vg_lite_grad.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_image.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nema_gfx --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/dma2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_rect.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_arc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/opengles --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas/dave2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_buf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture/lv_3dtexture.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown/lv_dropdown.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_header_arrow.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_header_dropdown.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_chinese.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ/lv_objx_templ.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale/lv_scale.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led/lv_led.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property/lv_obj_property_names.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property/lv_style_properties.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win/lv_win.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart/lv_chart.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox/lv_checkbox.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label/lv_label.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox/lv_spinbox.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage/lv_animimage.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider/lv_slider.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix/lv_buttonmatrix.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner/lv_spinner.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image/lv_image.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas/lv_canvas.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview/lv_tabview.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox/lv_msgbox.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea/lv_textarea.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button/lv_button.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span/lv_span.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton/lv_imagebutton.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line/lv_line.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table/lv_table.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list/lv_list.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu/lv_menu.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch/lv_switch.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie/lv_lottie.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar/lv_bar.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc/lv_arc.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller/lv_roller.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview/lv_tileview.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard/lv_keyboard.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick/lv_tick.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_bidi_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_color_op_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_area_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_fs_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/lv_cache_entry_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/instance --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/cache/class --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_text_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_profiler_builtin_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_anim_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_timer_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_style_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_rb_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/misc/lv_event_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/qnx --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/uefi/lv_uefi_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/evdev/lv_evdev_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_xkb_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/libinput/lv_libinput_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/wayland --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/sdl/lv_sdl_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/x11 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7796 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/drm --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/fb --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/renesas_glcdc --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7735 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ft81x --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/lcd --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st_ltdc --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/ili9341 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/tft_espi --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/display/st7789 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/glfw --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/windows --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/drivers/nuttx --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_group_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_draw_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_refr_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_scroll_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_event_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_class_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/core/lv_obj_style_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_linux_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/osal/lv_os_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/snapshot --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/ime/lv_ime_pinyin_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/vg_lite_tvg --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/test/lv_test_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/file_explorer/lv_file_explorer_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/observer/lv_observer_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_component_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/parsers --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/xml/lv_xml_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/fragment/lv_fragment_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/gridnav --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/monkey/lv_monkey_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/sysmon/lv_sysmon_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/imgfont --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/others/font_manager --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/svg --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/internal --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/error --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/thorvg/rapidjson/msinttypes --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libjpeg_turbo --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/ffmpeg/lv_ffmpeg_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/libpng --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tjpgd --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/tiny_ttf --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/freetype/lv_freetype_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/expat --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lz4 --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/qrcode/lv_qrcode_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rle --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/lodepng --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bin_decoder --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/rlottie/lv_rlottie_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/fsdrv --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/bmp --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/barcode/lv_barcode_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/libs/gif/lv_gif_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/lv_layout_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/flex --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/layouts/grid --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/lvgl_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/uefi --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/clib --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/micropython --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/lv_mem_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/rtthread --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/stdlib/builtin/lv_tlsf_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_gesture_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/indev/lv_indev_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/display/lv_display_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/font/lv_font_fmt_txt_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/default --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/simple --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/lv_theme_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/themes/mono --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_label_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/helium --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/lv_draw_sw_blend_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/arm2d --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/blend/neon --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/arm2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sw/lv_draw_sw_mask_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_buf_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/g2d --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/pxp --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nxp/vglite --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_image_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_mask_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_rect_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/sdl --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/vg_lite --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/nema_gfx --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_triangle_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/dma2d --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/opengles --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_image_decoder_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/renesas/dave2d --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/draw/lv_draw_vector_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/3dtexture/lv_3dtexture_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/dropdown/lv_dropdown_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/calendar/lv_calendar_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/objx_templ --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/scale/lv_scale_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/led/lv_led_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/property --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/win/lv_win_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/chart/lv_chart_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/checkbox/lv_checkbox_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/label/lv_label_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinbox/lv_spinbox_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/animimage/lv_animimage_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/slider/lv_slider_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/buttonmatrix/lv_buttonmatrix_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/spinner --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/image/lv_image_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/canvas/lv_canvas_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tabview/lv_tabview_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/msgbox/lv_msgbox_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/textarea/lv_textarea_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/button/lv_button_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/span/lv_span_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/imagebutton/lv_imagebutton_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/line/lv_line_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/table/lv_table_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/list --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/menu/lv_menu_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/switch/lv_switch_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/lottie/lv_lottie_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/bar/lv_bar_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/arc/lv_arc_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/roller/lv_roller_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/tileview/lv_tileview_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/widgets/keyboard/lv_keyboard_private.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/src/tick/lv_tick_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_conf.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h --- Installing: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/lib/liblvgl.a --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/share/pkgconfig/lvgl.pc --- Installing: /tmp/tmp.w0Bd5jwcNB/lib/liblvgl_thorvg.a --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lv_version.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl.h --- Up-to-date: /tmp/tmp.w0Bd5jwcNB/include/lvgl/lvgl_private.h --- Installing: /tmp/tmp.w0Bd5jwcNB/lib/libjknative.a -20:15:14 info gmake[1]: warning: Clock skew detected. Your build may be incomplete. -gmake[1]: Leaving directory '/workspaces/kvm/internal/native/cgo/build' -Building release... -GOOS=linux GOARCH=arm GOARM=7 ARCHFLAGS="-arch arm" CGO_CFLAGS="-I/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/include -I/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/sysroot/usr/include" CGO_LDFLAGS="-L/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/lib -L/opt/jetkvm-native-buildkit/arm-rockchip830-linux-uclibcgnueabihf/sysroot/usr/lib -lrockit -lrockchip_mpp -lrga -lpthread -lm" CC="/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc" LD="/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-ld" CGO_ENABLED=1 go build \ - -ldflags="-s -w -X github.com/prometheus/common/version.Branch=feat/multisession-support -X github.com/prometheus/common/version.BuildDate=2025-10-08T17:14:49+0000 -X github.com/prometheus/common/version.Revision=81bc5055dbcb97923288f523a97ffc892fe682f7 -X github.com/jetkvm/kvm.builtTimestamp=1759943689 -X github.com/jetkvm/kvm.builtAppVersion=0.4.8" \ - -trimpath -tags netgo,timetzdata,nomsgpack \ - -o bin/jetkvm_app cmd/main.go -20:15:19 info + set +x -Deployment complete. -20:15:31 error Error tunneling to container: wait: remote command exited without exit status or exit signal diff --git a/internal/session/permissions.go b/internal/session/permissions.go index 3c60cd20..6db9316e 100644 --- a/internal/session/permissions.go +++ b/internal/session/permissions.go @@ -101,8 +101,8 @@ var RolePermissions = map[SessionMode]PermissionSet{ PermissionSessionRequestPrimary: true, }, SessionModePending: { - // Pending sessions have minimal permissions - PermissionVideoView: true, + // Pending sessions have NO permissions until approved + // This prevents unauthorized video access }, } From a1548fe5b1d2009652e25a52ae23db84a3bddac5 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 21:37:02 +0300 Subject: [PATCH 4/7] 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. --- config.go | 9 +- jsonrpc.go | 22 ++++- main.go | 9 +- session_manager.go | 13 +-- ui/src/api/sessionApi.ts | 12 +++ ui/src/components/AccessDeniedOverlay.tsx | 54 +++++++++-- .../UnifiedSessionRequestDialog.tsx | 89 +++++++++++-------- ui/src/hooks/stores.ts | 6 ++ ui/src/hooks/useSessionEvents.ts | 6 +- ui/src/hooks/useSessionManagement.ts | 7 +- .../devices.$id.settings.multi-session.tsx | 43 ++++++++- ui/src/routes/devices.$id.tsx | 17 +++- ui/src/stores/sessionStore.ts | 17 +++- web.go | 13 +-- 14 files changed, 237 insertions(+), 80 deletions(-) diff --git a/config.go b/config.go index 024a5844..7347e1c3 100644 --- a/config.go +++ b/config.go @@ -157,10 +157,11 @@ var defaultConfig = &Config{ DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes SessionSettings: &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, // 10 seconds default - PrivateKeystrokes: false, // By default, share keystrokes with observers + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, }, JigglerEnabled: false, // This is the "Standard" jiggler option in the UI diff --git a/jsonrpc.go b/jsonrpc.go index 423c71f6..57d41db2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -210,7 +210,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{ "message": "Access denied by primary session", }, targetSession) - sessionManager.RemoveSession(sessionID) + sessionManager.broadcastSessionListUpdate() result = map[string]interface{}{"status": "denied"} } else { handlerErr = errors.New("session not found or not pending") @@ -218,6 +218,26 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { } 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) diff --git a/main.go b/main.go index b59b83a4..276d30a3 100644 --- a/main.go +++ b/main.go @@ -19,10 +19,11 @@ func Main() { // Initialize currentSessionSettings to use config's persistent SessionSettings if config.SessionSettings == nil { config.SessionSettings = &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, - PrivateKeystrokes: false, + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, } _ = SaveConfig() } diff --git a/session_manager.go b/session_manager.go index 6dc388e0..42455385 100644 --- a/session_manager.go +++ b/session_manager.go @@ -147,16 +147,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe sm.mu.Lock() defer sm.mu.Unlock() - // Check if this session ID is within grace period for reconnection wasWithinGracePeriod := false wasPreviouslyPrimary := false + wasPreviouslyPending := false if graceTime, exists := sm.reconnectGrace[session.ID]; exists { if time.Now().Before(graceTime) { wasWithinGracePeriod = true - // Check if this was specifically the primary wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) + if reconnectInfo, hasInfo := sm.reconnectInfo[session.ID]; hasInfo { + wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending) + } } - // Clean up grace period entry delete(sm.reconnectGrace, session.ID) } @@ -265,6 +266,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe Int("totalSessions", len(sm.sessions)). Bool("wasWithinGracePeriod", wasWithinGracePeriod). Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("wasPreviouslyPending", wasPreviouslyPending). Bool("isBlacklisted", isBlacklisted). Msg("AddSession state analysis") @@ -313,12 +315,11 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe // Reset HID availability to force re-handshake for input functionality session.hidRPCAvailable = false } else { - // Someone else became primary in the meantime, become observer session.Mode = SessionModeObserver } + } else if wasPreviouslyPending { + session.Mode = SessionModePending } else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { - // New session requires approval from primary (but only if there IS a primary to approve) - // Skip approval for sessions reconnecting within grace period session.Mode = SessionModePending // Notify primary about the pending session, but only if nickname is not required OR already provided if primary := sm.sessions[sm.primarySessionID]; primary != nil { diff --git a/ui/src/api/sessionApi.ts b/ui/src/api/sessionApi.ts index 0cca4634..b6602fe4 100644 --- a/ui/src/api/sessionApi.ts +++ b/ui/src/api/sessionApi.ts @@ -116,5 +116,17 @@ export const sessionApi = { } }); }); + }, + + requestSessionApproval: async (sendFn: RpcSendFunction): Promise => { + return new Promise((resolve, reject) => { + sendFn("requestSessionApproval", {}, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); } }; \ No newline at end of file diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx index 484be9b0..f9aa6aa5 100644 --- a/ui/src/components/AccessDeniedOverlay.tsx +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -4,7 +4,7 @@ import { XCircleIcon } from "@heroicons/react/24/outline"; import { DEVICE_API, CLOUD_API } from "@/ui.config"; import { isOnDevice } from "@/main"; -import { useUserStore } from "@/hooks/stores"; +import { useUserStore, useSettingsStore } from "@/hooks/stores"; import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; import api from "@/api"; @@ -14,18 +14,22 @@ interface AccessDeniedOverlayProps { show: boolean; message?: string; onRetry?: () => void; + onRequestApproval?: () => void; } export default function AccessDeniedOverlay({ show, message = "Your session access was denied", - onRetry + onRetry, + onRequestApproval }: AccessDeniedOverlayProps) { const navigate = useNavigate(); const setUser = useUserStore(state => state.setUser); - const { clearSession } = useSessionStore(); + 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 { @@ -48,11 +52,17 @@ export default function AccessDeniedOverlay({ useEffect(() => { if (!show) return; + const newCount = incrementRejectionCount(); + + if (newCount >= maxRejectionAttempts) { + const hideTimer = setTimeout(() => {}, 3000); + return () => clearTimeout(hideTimer); + } + const timer = setInterval(() => { setCountdown(prev => { if (prev <= 1) { clearInterval(timer); - // Auto-redirect with proper logout handleLogout(); return 0; } @@ -61,10 +71,14 @@ export default function AccessDeniedOverlay({ }, 1000); return () => clearInterval(timer); - }, [show, handleLogout]); + }, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]); if (!show) return null; + if (rejectionCount >= maxRejectionAttempts) { + return null; + } + return (
@@ -88,17 +102,41 @@ export default function AccessDeniedOverlay({

+ {rejectionCount < maxRejectionAttempts && ( +
+

+ Attempt {rejectionCount} of {maxRejectionAttempts}: {rejectionCount === maxRejectionAttempts - 1 + ? "This is your last attempt. Further rejections will hide this dialog." + : `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.` + } +

+
+ )} +

Redirecting in {countdown} seconds...

- {onRetry && ( + {(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
)} -
-
+ {onDismiss && ( +
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 180fb985..a3a8f301 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -335,6 +335,9 @@ export interface SettingsState { requireSessionApproval: boolean; setRequireSessionApproval: (required: boolean) => void; + maxRejectionAttempts: number; + setMaxRejectionAttempts: (attempts: number) => void; + displayRotation: string; setDisplayRotation: (rotation: string) => void; @@ -381,6 +384,9 @@ export const useSettingsStore = create( requireSessionApproval: true, setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }), + maxRejectionAttempts: 3, + setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }), + displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts index b5c6804a..66a667de 100644 --- a/ui/src/hooks/useSessionEvents.ts +++ b/ui/src/hooks/useSessionEvents.ts @@ -69,12 +69,16 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) { const previousMode = currentModeFromStore; updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending"); - // Clear requesting state when mode changes from queued 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 diff --git a/ui/src/hooks/useSessionManagement.ts b/ui/src/hooks/useSessionManagement.ts index 4b688368..c35c3852 100644 --- a/ui/src/hooks/useSessionManagement.ts +++ b/ui/src/hooks/useSessionManagement.ts @@ -147,15 +147,10 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) { setSessionError("Your primary control request was denied"); } - // Handle session access denial (when your new session is denied) if (method === "sessionAccessDenied") { - const { clearSession, setSessionError } = useSessionStore.getState(); + const { setSessionError } = useSessionStore.getState(); const errorParams = params as { message?: string }; setSessionError(errorParams.message || "Session access was denied by the primary session"); - // Clear session data as we're being disconnected - setTimeout(() => { - clearSession(); - }, 3000); // Give user time to see the error } }, [handleSessionEvent, hasPermission, requireSessionApproval]); diff --git a/ui/src/routes/devices.$id.settings.multi-session.tsx b/ui/src/routes/devices.$id.settings.multi-session.tsx index 1c6970e6..2efafdac 100644 --- a/ui/src/routes/devices.$id.settings.multi-session.tsx +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -21,7 +21,9 @@ export default function SessionsSettings() { requireSessionNickname, setRequireSessionNickname, requireSessionApproval, - setRequireSessionApproval + setRequireSessionApproval, + maxRejectionAttempts, + setMaxRejectionAttempts } = useSettingsStore(); const [reconnectGrace, setReconnectGrace] = useState(10); @@ -38,7 +40,8 @@ export default function SessionsSettings() { requireNickname: boolean; reconnectGrace?: number; primaryTimeout?: number; - privateKeystrokes?: boolean + privateKeystrokes?: boolean; + maxRejectionAttempts?: number; }; setRequireSessionApproval(settings.requireApproval); setRequireSessionNickname(settings.requireNickname); @@ -51,9 +54,12 @@ export default function SessionsSettings() { if (settings.privateKeystrokes !== undefined) { setPrivateKeystrokes(settings.privateKeystrokes); } + if (settings.maxRejectionAttempts !== undefined) { + setMaxRejectionAttempts(settings.maxRejectionAttempts); + } } }); - }, [send, setRequireSessionApproval, setRequireSessionNickname]); + }, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]); const updateSessionSettings = (updates: Partial<{ requireApproval: boolean; @@ -61,6 +67,7 @@ export default function SessionsSettings() { reconnectGrace: number; primaryTimeout: number; privateKeystrokes: boolean; + maxRejectionAttempts: number; }>) => { if (!canModifySettings) { notify.error("Only the primary session can change this setting"); @@ -74,6 +81,7 @@ export default function SessionsSettings() { reconnectGrace: reconnectGrace, primaryTimeout: primaryTimeout, privateKeystrokes: privateKeystrokes, + maxRejectionAttempts: maxRejectionAttempts, ...updates } }, (response: JsonRpcResponse) => { @@ -149,6 +157,35 @@ export default function SessionsSettings() { /> + +
+ { + 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" + /> + attempts +
+
+ { - setAccessDenied(false); - // Attempt to reconnect - window.location.reload(); + onRequestApproval={async () => { + if (!send) return; + try { + await sessionApi.requestSessionApproval(send); + setAccessDenied(false); + } catch (error) { + console.error("Failed to re-request approval:", error); + } }} /> diff --git a/ui/src/stores/sessionStore.ts b/ui/src/stores/sessionStore.ts index 3e7a57b9..eee8aa0a 100644 --- a/ui/src/stores/sessionStore.ts +++ b/ui/src/stores/sessionStore.ts @@ -24,6 +24,7 @@ export interface SessionState { // UI state isRequestingPrimary: boolean; sessionError: string | null; + rejectionCount: number; // Actions setCurrentSession: (id: string, mode: SessionMode) => void; @@ -32,6 +33,8 @@ export interface SessionState { setSessionError: (error: string | null) => void; updateSessionMode: (mode: SessionMode) => void; clearSession: () => void; + incrementRejectionCount: () => number; + resetRejectionCount: () => void; // Computed getters isPrimary: () => boolean; @@ -52,6 +55,7 @@ export const useSessionStore = create()( sessions: [], isRequestingPrimary: false, sessionError: null, + rejectionCount: 0, // Actions setCurrentSession: (id: string, mode: SessionMode) => { @@ -84,10 +88,21 @@ export const useSessionStore = create()( currentMode: null, sessions: [], sessionError: null, - isRequestingPrimary: false + 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"; diff --git a/web.go b/web.go index 1c66ec6d..ecd7641c 100644 --- a/web.go +++ b/web.go @@ -44,12 +44,13 @@ type WebRTCSessionRequest struct { } 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 + 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"` } type SetPasswordRequest struct { From ffc4a2af219d200e17bdf3705baccedd586b6074 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 21:42:59 +0300 Subject: [PATCH 5/7] 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. --- ui/src/components/AccessDeniedOverlay.tsx | 3 +-- ui/src/routes/devices.$id.tsx | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx index f9aa6aa5..fd7f6f24 100644 --- a/ui/src/components/AccessDeniedOverlay.tsx +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -55,8 +55,7 @@ export default function AccessDeniedOverlay({ const newCount = incrementRejectionCount(); if (newCount >= maxRejectionAttempts) { - const hideTimer = setTimeout(() => {}, 3000); - return () => clearTimeout(hideTimer); + return; } const timer = setInterval(() => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index ad16119c..4ab6e80f 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -889,9 +889,10 @@ export default function KvmIdRoute() { useEffect(() => { if (appVersion) return; + if (!hasPermission(Permission.VIDEO_VIEW)) return; getLocalVersion(); - }, [appVersion, getLocalVersion]); + }, [appVersion, getLocalVersion, hasPermission]); const ConnectionStatusElement = useMemo(() => { const hasConnectionFailed = From f9ebd6ac2f606a050b0bb486a19fa43e236387f2 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 23:44:10 +0300 Subject: [PATCH 6/7] 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 --- session_manager.go | 138 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/session_manager.go b/session_manager.go index 42455385..afae20ee 100644 --- a/session_manager.go +++ b/session_manager.go @@ -255,6 +255,20 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe // and assign primary status atomically to prevent race conditions primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil + // Check if there's an active grace period for a primary session (different from this session) + hasActivePrimaryGracePeriod := false + if sm.lastPrimaryID != "" && sm.lastPrimaryID != session.ID { + if graceTime, exists := sm.reconnectGrace[sm.lastPrimaryID]; exists { + if time.Now().Before(graceTime) { + if reconnectInfo, hasInfo := sm.reconnectInfo[sm.lastPrimaryID]; hasInfo { + if reconnectInfo.Mode == SessionModePrimary { + hasActivePrimaryGracePeriod = true + } + } + } + } + } + // Check if this session was recently demoted via transfer isBlacklisted := sm.isSessionBlacklisted(session.ID) @@ -263,6 +277,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe Str("nickname", session.Nickname). Str("currentPrimarySessionID", sm.primarySessionID). Bool("primaryExists", primaryExists). + Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod). Int("totalSessions", len(sm.sessions)). Bool("wasWithinGracePeriod", wasWithinGracePeriod). Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). @@ -271,10 +286,10 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe Msg("AddSession state analysis") // Become primary only if: - // 1. Was previously primary (within grace) AND no current primary, OR - // 2. There's no primary at all AND not recently transferred away - // Never allow primary promotion if already restored within grace period - shouldBecomePrimary := !wasWithinGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted)) + // 1. Was previously primary (within grace) AND no current primary AND no other session has grace period, OR + // 2. There's no primary at all AND not recently transferred away AND no active grace period + // Never allow primary promotion if already restored within grace period or another session has grace period + shouldBecomePrimary := !wasWithinGracePeriod && !hasActivePrimaryGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted)) if wasWithinGracePeriod { sm.logger.Debug(). @@ -354,6 +369,12 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe // This ensures that primary existence checks work correctly during restoration sm.sessions[session.ID] = session + sm.logger.Info(). + Str("sessionID", session.ID). + Str("mode", string(session.Mode)). + Int("totalSessions", len(sm.sessions)). + Msg("Session added to manager") + // Ensure session has auto-generated nickname if needed sm.ensureNickname(session) @@ -373,12 +394,21 @@ func (sm *SessionManager) RemoveSession(sessionID string) { session, exists := sm.sessions[sessionID] if !exists { + sm.logger.Debug(). + Str("sessionID", sessionID). + Msg("RemoveSession called but session not found in map") return } wasPrimary := session.Mode == SessionModePrimary delete(sm.sessions, sessionID) + sm.logger.Info(). + Str("sessionID", sessionID). + Bool("wasPrimary", wasPrimary). + Int("remainingSessions", len(sm.sessions)). + Msg("Session removed from manager") + // Remove from queue if present sm.removeFromQueue(sessionID) @@ -428,10 +458,18 @@ func (sm *SessionManager) RemoveSession(sessionID string) { sm.logger.Info(). Str("sessionID", sessionID). Dur("gracePeriod", time.Duration(gracePeriod)*time.Second). - Msg("Primary session removed, grace period active - auto-promotion will occur after grace expires") + Int("remainingSessions", len(sm.sessions)). + Msg("Primary session removed, grace period active") - // NOTE: Do NOT call validateSinglePrimary() here - let grace period expire naturally - // The cleanupInactiveSessions() function will handle promotion after grace period expires + // Immediate promotion check: if there are observers waiting, trigger validation + // This allows immediate promotion while still respecting grace period protection + if len(sm.sessions) > 0 { + sm.logger.Debug(). + Str("removedPrimaryID", sessionID). + Int("remainingSessions", len(sm.sessions)). + Msg("Triggering immediate validation for potential promotion") + sm.validateSinglePrimary() + } } // Notify remaining sessions @@ -704,6 +742,20 @@ func (sm *SessionManager) TransferPrimary(fromID, toID string) error { sm.mu.Lock() defer sm.mu.Unlock() + // SECURITY: Verify fromID is the actual current primary + if sm.primarySessionID != fromID { + return fmt.Errorf("transfer denied: %s is not the current primary (current primary: %s)", fromID, sm.primarySessionID) + } + + fromSession, exists := sm.sessions[fromID] + if !exists { + return ErrSessionNotFound + } + + if fromSession.Mode != SessionModePrimary { + return errors.New("transfer denied: from session is not in primary mode") + } + // Use centralized transfer method err := sm.transferPrimaryRole(fromID, toID, "direct_transfer", "manual transfer request") if err != nil { @@ -899,20 +951,64 @@ func (sm *SessionManager) validateSinglePrimary() { sm.primarySessionID = "" } + // Check if there's an active grace period for any primary session + // BUT: if grace period just started (within 2 seconds), allow immediate promotion + hasActivePrimaryGracePeriod := false + for sessionID, graceTime := range sm.reconnectGrace { + if time.Now().Before(graceTime) { + if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo { + if reconnectInfo.Mode == SessionModePrimary { + // Calculate how long ago the grace period started + gracePeriod := 10 + if currentSessionSettings != nil && currentSessionSettings.ReconnectGrace > 0 { + gracePeriod = currentSessionSettings.ReconnectGrace + } + graceStartTime := graceTime.Add(-time.Duration(gracePeriod) * time.Second) + timeSinceGraceStart := time.Since(graceStartTime) + + // If grace period just started (within 2 seconds), allow immediate promotion + // This enables instant promotion on logout while still protecting against network blips + if timeSinceGraceStart > 2*time.Second { + hasActivePrimaryGracePeriod = true + sm.logger.Debug(). + Str("gracePrimaryID", sessionID). + Dur("remainingGrace", time.Until(graceTime)). + Dur("timeSinceGraceStart", timeSinceGraceStart). + Msg("Active grace period detected for primary session - blocking auto-promotion") + } else { + sm.logger.Debug(). + Str("gracePrimaryID", sessionID). + Dur("timeSinceGraceStart", timeSinceGraceStart). + Msg("Grace period just started - allowing immediate promotion") + } + break + } + } + } + } + + // Build session IDs list for debugging + sessionIDs := make([]string, 0, len(sm.sessions)) + for id := range sm.sessions { + sessionIDs = append(sessionIDs, id) + } + sm.logger.Debug(). Int("primarySessionCount", len(primarySessions)). Str("primarySessionID", sm.primarySessionID). Int("totalSessions", len(sm.sessions)). + Strs("sessionIDs", sessionIDs). + Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod). Msg("validateSinglePrimary state check") - // Auto-promote if there are NO primary sessions at all - if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 { + // Auto-promote if there are NO primary sessions at all AND no active grace period + if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod { // Find a session to promote to primary nextSessionID := sm.findNextSessionToPromote() if nextSessionID != "" { sm.logger.Info(). Str("promotedSessionID", nextSessionID). - Msg("Auto-promoting observer to primary - no primary sessions exist") + Msg("Auto-promoting observer to primary - no primary sessions exist and no grace period active") // Use the centralized promotion logic err := sm.transferPrimaryRole("", nextSessionID, "emergency_auto_promotion", "no primary sessions detected") @@ -931,6 +1027,7 @@ func (sm *SessionManager) validateSinglePrimary() { Int("primarySessions", len(primarySessions)). Str("primarySessionID", sm.primarySessionID). Bool("hasSessions", len(sm.sessions) > 0). + Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod). Msg("Emergency auto-promotion conditions not met") } } @@ -944,6 +1041,15 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf return ErrSessionNotFound } + // SECURITY: Prevent promoting a session that's already primary + if toSession.Mode == SessionModePrimary { + sm.logger.Warn(). + Str("sessionID", toSessionID). + Str("transferType", transferType). + Msg("Attempted to promote session that is already primary") + return errors.New("target session is already primary") + } + var fromSession *Session var fromExists bool if fromSessionID != "" { @@ -967,6 +1073,18 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf Msg("Demoted existing primary session") } + // SECURITY: Before promoting, verify there are no other primary sessions + for id, sess := range sm.sessions { + if id != toSessionID && sess.Mode == SessionModePrimary { + sm.logger.Error(). + Str("existingPrimaryID", id). + Str("targetPromotionID", toSessionID). + Str("transferType", transferType). + Msg("CRITICAL: Attempted to create second primary - blocking promotion") + return fmt.Errorf("cannot promote: another primary session exists (%s)", id) + } + } + // Promote target session toSession.Mode = SessionModePrimary toSession.hidRPCAvailable = false // Force re-handshake From 541d2bd77d608bb5f07023a4164ad8b63f09451a Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 23:58:27 +0300 Subject: [PATCH 7/7] 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 --- session_manager.go | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/session_manager.go b/session_manager.go index afae20ee..652d3f4f 100644 --- a/session_manager.go +++ b/session_manager.go @@ -888,6 +888,17 @@ func (sm *SessionManager) UpdateLastActive(sessionID string) { func (sm *SessionManager) validateSinglePrimary() { primarySessions := make([]*Session, 0) + sm.logger.Debug(). + Int("sm.sessions_len", len(sm.sessions)). + Interface("sm.sessions_keys", func() []string { + keys := make([]string, 0, len(sm.sessions)) + for k := range sm.sessions { + keys = append(keys, k) + } + return keys + }()). + Msg("validateSinglePrimary: checking sm.sessions map") + // Find all sessions that think they're primary for _, session := range sm.sessions { if session.Mode == SessionModePrimary { @@ -952,35 +963,16 @@ func (sm *SessionManager) validateSinglePrimary() { } // Check if there's an active grace period for any primary session - // BUT: if grace period just started (within 2 seconds), allow immediate promotion hasActivePrimaryGracePeriod := false for sessionID, graceTime := range sm.reconnectGrace { if time.Now().Before(graceTime) { if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo { if reconnectInfo.Mode == SessionModePrimary { - // Calculate how long ago the grace period started - gracePeriod := 10 - if currentSessionSettings != nil && currentSessionSettings.ReconnectGrace > 0 { - gracePeriod = currentSessionSettings.ReconnectGrace - } - graceStartTime := graceTime.Add(-time.Duration(gracePeriod) * time.Second) - timeSinceGraceStart := time.Since(graceStartTime) - - // If grace period just started (within 2 seconds), allow immediate promotion - // This enables instant promotion on logout while still protecting against network blips - if timeSinceGraceStart > 2*time.Second { - hasActivePrimaryGracePeriod = true - sm.logger.Debug(). - Str("gracePrimaryID", sessionID). - Dur("remainingGrace", time.Until(graceTime)). - Dur("timeSinceGraceStart", timeSinceGraceStart). - Msg("Active grace period detected for primary session - blocking auto-promotion") - } else { - sm.logger.Debug(). - Str("gracePrimaryID", sessionID). - Dur("timeSinceGraceStart", timeSinceGraceStart). - Msg("Grace period just started - allowing immediate promotion") - } + hasActivePrimaryGracePeriod = true + sm.logger.Debug(). + Str("gracePrimaryID", sessionID). + Dur("remainingGrace", time.Until(graceTime)). + Msg("Active grace period detected for primary session - blocking auto-promotion") break } }