diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/JoinResponse.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/JoinResponse.cs index 1529d0fe..7f2266a5 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/JoinResponse.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/JoinResponse.cs @@ -64,6 +64,12 @@ namespace PepperDash.Essentials.WebSocketServer [JsonProperty("userAppUrl")] public string UserAppUrl { get; set; } + /// + /// Gets or sets the WebSocketUrl with clientId query parameter + /// + [JsonProperty("webSocketUrl")] + public string WebSocketUrl { get; set; } + /// /// Gets or sets the EnableDebug diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 25cd7ba7..252d2814 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -68,6 +68,12 @@ namespace PepperDash.Essentials.WebSocketServer /// private readonly ConcurrentDictionary pendingClientRegistrations = new ConcurrentDictionary(); + /// + /// Stores queues of pending client IDs per token for legacy clients (FIFO) + /// This ensures thread-safety when multiple legacy clients use the same token + /// + private readonly ConcurrentDictionary> legacyClientIdQueues = new ConcurrentDictionary>(); + /// /// Gets the collection of UI clients /// @@ -730,49 +736,95 @@ namespace PepperDash.Essentials.WebSocketServer private UiClient BuildUiClient(string roomKey, JoinToken token, string key) { - // Try to retrieve a pending client ID that was registered during the join request - // Use the composite key: token-clientId - string clientId = null; - string registrationKey = null; - - // Find a registration for this token - var matchingRegistrations = pendingClientRegistrations.Keys - .Where(k => k.StartsWith($"{key}-")) - .OrderBy(k => k) // Process in order for FIFO behavior - .ToList(); - - if (matchingRegistrations.Any()) + // Dequeue the next clientId for legacy client support (FIFO per token) + // New clients will override this ID in OnOpen with the validated query parameter value + var clientId = "pending"; + if (legacyClientIdQueues.TryGetValue(key, out var queue) && queue.TryDequeue(out var dequeuedId)) { - registrationKey = matchingRegistrations.First(); - if (pendingClientRegistrations.TryRemove(registrationKey, out clientId)) - { - this.LogVerbose("Retrieved pending clientId {clientId} for token {key}", clientId, key); - } - } - - if (clientId == null) - { - // Fallback: generate a new client ID if none was pending - clientId = $"{Utilities.GetNextClientId()}"; - this.LogWarning("No pending clientId found for token {key}, generated new ID {clientId}", key, clientId); + clientId = dequeuedId; + this.LogVerbose("Dequeued legacy clientId {clientId} for token {token}", clientId, key); } var c = new UiClient($"uiclient-{key}-{roomKey}-{clientId}", clientId, token.Token, token.TouchpanelKey); - this.LogInformation("Constructing UiClient with key {key} and ID {id}", key, clientId); + this.LogInformation("Constructing UiClient with key {key} and temporary ID (will be set from query param)", key); c.Controller = _parent; c.RoomKey = roomKey; c.TokenKey = key; // Store the URL token key for filtering + c.Server = this; // Give UiClient access to server for ID registration - uiClients.AddOrUpdate(clientId, c, (id, existingClient) => + // Don't add to uiClients yet - will be added in OnOpen after ID is set from query param + + c.ConnectionClosed += (o, a) => { - this.LogWarning("replacing client with duplicate id {id}", id); - return c; - }); - // UiClients[key].SetClient(c); - c.ConnectionClosed += (o, a) => uiClients.TryRemove(a.ClientId, out _); + uiClients.TryRemove(a.ClientId, out _); + // Clean up any pending registrations for this token + var keysToRemove = pendingClientRegistrations.Keys + .Where(k => k.StartsWith($"{key}-")) + .ToList(); + foreach (var k in keysToRemove) + { + pendingClientRegistrations.TryRemove(k, out _); + } + + // Clean up legacy queue if empty + if (legacyClientIdQueues.TryGetValue(key, out var legacyQueue) && legacyQueue.IsEmpty) + { + legacyClientIdQueues.TryRemove(key, out _); + } + }; return c; } + /// + /// Registers a UiClient with its validated client ID after WebSocket connection + /// + /// The UiClient to register + /// The validated client ID + /// The token key for validation + /// True if registration successful, false if validation failed + public bool RegisterUiClient(UiClient client, string clientId, string tokenKey) + { + var registrationKey = $"{tokenKey}-{clientId}"; + + // Verify this clientId was generated during a join request for this token + if (!pendingClientRegistrations.TryRemove(registrationKey, out _)) + { + this.LogWarning("Client attempted to connect with unregistered or expired clientId {clientId} for token {token}", clientId, tokenKey); + return false; + } + + // Registration is valid - add to active clients + uiClients.AddOrUpdate(clientId, client, (id, existingClient) => + { + this.LogWarning("Replacing existing client with duplicate id {id}", id); + return client; + }); + + this.LogInformation("Successfully registered UiClient with ID {clientId} for token {token}", clientId, tokenKey); + return true; + } + + /// + /// Registers a UiClient using legacy flow (for backwards compatibility with older clients) + /// + /// The UiClient to register + public void RegisterLegacyUiClient(UiClient client) + { + if (string.IsNullOrEmpty(client.Id)) + { + this.LogError("Cannot register client with null or empty ID"); + return; + } + + uiClients.AddOrUpdate(client.Id, client, (id, existingClient) => + { + this.LogWarning("Replacing existing client with duplicate id {id} (legacy flow)", id); + return client; + }); + + this.LogInformation("Successfully registered UiClient with ID {clientId} using legacy flow", client.Id); + } + /// /// Prints out the session data for each path /// @@ -1079,15 +1131,23 @@ namespace PepperDash.Essentials.WebSocketServer }); } - // Generate a client ID for this join request and store it with a composite key + // Generate a client ID for this join request var clientId = $"{Utilities.GetNextClientId()}"; - // Store registration with composite key: token-clientId so BuildUiClient can verify it + // Store in pending registrations for new clients that send clientId via query param var registrationKey = $"{token}-{clientId}"; pendingClientRegistrations.TryAdd(registrationKey, clientId); + + // Also enqueue for legacy clients (thread-safe FIFO per token) + var queue = legacyClientIdQueues.GetOrAdd(token, _ => new ConcurrentQueue()); + queue.Enqueue(clientId); this.LogVerbose("Assigning ClientId: {clientId} for token: {token}", clientId, token); + // Construct WebSocket URL with clientId query parameter + var wsProtocol = "ws"; + var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; + // Construct the response object JoinResponse jRes = new JoinResponse { @@ -1101,6 +1161,7 @@ namespace PepperDash.Essentials.WebSocketServer UserAppUrl = string.Format("http://{0}:{1}/mc/app", CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port), + WebSocketUrl = wsUrl, EnableDebug = false, DeviceInterfaceSupport = deviceInterfaces }; diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs index 61e5d042..fd012bbe 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs @@ -46,6 +46,11 @@ namespace PepperDash.Essentials.WebSocketServer /// public MobileControlSystemController Controller { get; set; } + /// + /// Gets or sets the server instance for client registration + /// + public MobileControlWebsocketServer Server { get; set; } + /// /// Gets or sets the room key that this client is associated with /// @@ -104,6 +109,50 @@ namespace PepperDash.Essentials.WebSocketServer Log.Output = (data, message) => Utilities.ConvertWebsocketLog(data, message, this); Log.Level = LogLevel.Trace; + // Get clientId from query parameter + var queryString = Context.QueryString; + var clientId = queryString["clientId"]; + + if (!string.IsNullOrEmpty(clientId)) + { + // New behavior: Validate and register with the server using provided clientId + if (Server == null || !Server.RegisterUiClient(this, clientId, TokenKey)) + { + this.LogError("Failed to register client with ID {clientId}. Invalid or expired registration.", clientId); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "Invalid or expired clientId"); + return; + } + + // Update this client's ID to the validated one + Id = clientId; + Key = $"uiclient-{TokenKey}-{RoomKey}-{clientId}"; + + this.LogInformation("Client {clientId} successfully connected and registered (new flow)", clientId); + } + else + { + // Legacy behavior: Use clientId from Token.Id (generated in HandleJoinRequest) + this.LogInformation("Client connected without clientId query parameter. Using legacy registration flow."); + + // Id is already set from Token in constructor, use it + if (string.IsNullOrEmpty(Id)) + { + this.LogError("Legacy client has no ID from token. Connection will be closed."); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "No client ID available"); + return; + } + + Key = $"uiclient-{TokenKey}-{RoomKey}-{Id}"; + + // Register directly to active clients (legacy flow) + if (Server != null) + { + Server.RegisterLegacyUiClient(this); + } + + this.LogInformation("Client {clientId} registered using legacy flow", Id); + } + if (Controller == null) { Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Controller is null");