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