using System.Net.WebSockets; using System.Text; using System.Text.Json; namespace SwyxTray.Web; /// /// Eine Verbindung. Empfaengt Kommandos, beantwortet sie und nimmt vom Server /// die Zustandsmeldungen entgegen. /// /// Kommandos werden streng nacheinander abgearbeitet. Das kostet nichts — /// Anrufe kommen nicht im Buendel — und haelt die Zugriffe auf den COM-Server /// in der Reihenfolge, in der die Webseite sie gesendet hat. /// internal sealed class WebSocketSession : IDisposable { private const int MaxMessageBytes = 64 * 1024; private readonly WebSocket _socket; private readonly CommandExecutor _executor; private readonly PluginTabChannel _tabs; private readonly SemaphoreSlim _sendGate = new(1, 1); public int Id { get; } public string Origin { get; } /// /// Verbindung ueber den Plugin-Port: darauf gibt es nur die /// Tab-Verwaltung — keine Zustandsmeldungen, keine Telefonie-Kommandos. /// public bool IsPlugin { get; } public WebSocketSession( int id, WebSocket socket, CommandExecutor executor, PluginTabChannel tabs, string origin, bool isPlugin) { Id = id; _socket = socket; _executor = executor; _tabs = tabs; Origin = origin.Length > 0 ? origin : "(ohne Origin)"; IsPlugin = isPlugin; } public async Task RunAsync(CancellationToken cancellationToken) { await SendAsync(Protocol.Serialize(new HelloMessage( App: "SwyxTray", Version: typeof(WebSocketSession).Assembly.GetName().Version?.ToString() ?? "1.0", Protocol: WebSocketConfig.ProtocolVersion, Session: Id)), cancellationToken).ConfigureAwait(false); if (!IsPlugin) { await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false); } var buffer = new byte[8 * 1024]; var message = new MemoryStream(); while (_socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { var result = await _socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, cancellationToken) .ConfigureAwait(false); return; } if (result.MessageType == WebSocketMessageType.Binary) { await CloseAsync(WebSocketCloseStatus.InvalidMessageType, "Nur Text (JSON).", cancellationToken) .ConfigureAwait(false); return; } message.Write(buffer, 0, result.Count); if (message.Length > MaxMessageBytes) { await CloseAsync(WebSocketCloseStatus.MessageTooBig, "Nachricht zu gross.", cancellationToken) .ConfigureAwait(false); return; } if (!result.EndOfMessage) { continue; } var text = Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length); message.SetLength(0); await HandleAsync(text, cancellationToken).ConfigureAwait(false); } } private async Task HandleAsync(string text, CancellationToken cancellationToken) { ClientCommand? command; try { command = JsonSerializer.Deserialize(text, Protocol.Json); } catch (JsonException ex) { Log.Debug($"WebSocket {Id}: ungueltiges JSON — {ex.Message}"); await SendAsync(Protocol.Serialize(new ResultMessage(null, false, "Ungueltiges JSON.")), cancellationToken).ConfigureAwait(false); return; } if (command is null || string.IsNullOrWhiteSpace(command.Cmd)) { await SendAsync(Protocol.Serialize(new ResultMessage(command?.Id, false, "Feld 'cmd' fehlt.")), cancellationToken).ConfigureAwait(false); return; } Log.Debug($"WebSocket {Id}: Kommando '{command.Cmd}' (id={command.Id})."); if (IsPlugin) { await HandlePluginAsync(command, cancellationToken).ConfigureAwait(false); return; } // 'status' beantwortet der Server mit dem vollen Zustand samt der // zwischengespeicherten Adressdaten; die Quittung danach haelt den // Ablauf fuer den Client einheitlich. if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase)) { await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false); await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken) .ConfigureAwait(false); return; } var response = await _executor.ExecuteAsync(command, cancellationToken).ConfigureAwait(false); await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false); } /// /// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf /// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich /// ueber den Hauptport. /// private async Task HandlePluginAsync(ClientCommand command, CancellationToken cancellationToken) { switch (command.Cmd!.Trim().ToLowerInvariant()) { case "tabresult": // Die Antwort auf einen Tab-Auftrag — sie wird der wartenden // Anfrage zugeordnet und selbst nicht quittiert. if (!_tabs.TryComplete(command)) { Log.Debug($"WebSocket {Id}: tabresult {command.Id} ohne wartende Anfrage."); } return; case "ping": await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken) .ConfigureAwait(false); return; default: await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, false, "Auf dem Plugin-Port gibt es nur die Tab-Verwaltung (tabresult, ping).")), cancellationToken).ConfigureAwait(false); return; } } private async Task SendSnapshotAsync(CancellationToken cancellationToken) { var snapshot = await _executor.GetSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendAsync(Protocol.Serialize(SnapshotMessage.From(snapshot)), cancellationToken) .ConfigureAwait(false); } /// Stellt der Seite die zwischengespeicherten Adressdaten zu. private async Task SendAddressesAsync(CancellationToken cancellationToken) { var addresses = await _executor.GetAddressesAsync(cancellationToken).ConfigureAwait(false); await SendAsync(Protocol.Serialize(AddressesMessage.From(addresses)), cancellationToken) .ConfigureAwait(false); } /// /// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen. /// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem /// Empfangs-Thread kommen, ist die Sperre hier nicht optional. /// public Task SendAsync(string json, CancellationToken cancellationToken) => SendManyAsync([json], cancellationToken); /// /// Sendet mehrere Nachrichten am Stueck, ohne dass sich etwas dazwischen /// schieben kann — so kommen snapshot und die dazugehoerigen /// Anruf-Ereignisse immer in der erzeugten Reihenfolge an. /// public async Task SendManyAsync(IReadOnlyList jsons, CancellationToken cancellationToken) { await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { foreach (var json in jsons) { if (_socket.State != WebSocketState.Open) { return; } await _socket.SendAsync( Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); } } finally { _sendGate.Release(); } } /// Fuer das Verteilen an alle: ein toter Client darf nicht stoeren. public async Task TrySendManyAsync(IReadOnlyList jsons, CancellationToken cancellationToken) { try { await SendManyAsync(jsons, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException or IOException) { Log.Debug($"WebSocket {Id}: Senden fehlgeschlagen ({ex.GetType().Name})."); } } private async Task CloseAsync(WebSocketCloseStatus status, string reason, CancellationToken cancellationToken) { Log.Debug($"WebSocket {Id} wird geschlossen: {reason}"); await _socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false); } public void Dispose() { _socket.Dispose(); _sendGate.Dispose(); } }