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();
}
}