SwyxTray liest jetzt die Telefonbuecher (global und persoenlich) des SwyxIt!-Clients in einen Adress-Cache, bietet Kontaktsuche ueber FulltextSearchInContactsEx und Nummernaufloesung ueber DispResolveNumber; alles ueber den WebSocket-Zugang abrufbar. Das Spring-Boot-Modul core/ und das SwyxFFPlugin werden als eigene Projekte gepflegt und aus diesem Repository entfernt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
9.5 KiB
C#
259 lines
9.5 KiB
C#
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace SwyxTray.Web;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// Verbindung ueber den Plugin-Port: darauf gibt es nur die
|
|
/// Tab-Verwaltung — keine Zustandsmeldungen, keine Telefonie-Kommandos.
|
|
/// </summary>
|
|
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<ClientCommand>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf
|
|
/// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich
|
|
/// ueber den Hauptport.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Stellt der Seite die zwischengespeicherten Adressdaten zu.</summary>
|
|
private async Task SendAddressesAsync(CancellationToken cancellationToken)
|
|
{
|
|
var addresses = await _executor.GetAddressesAsync(cancellationToken).ConfigureAwait(false);
|
|
await SendAsync(Protocol.Serialize(AddressesMessage.From(addresses)), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Task SendAsync(string json, CancellationToken cancellationToken)
|
|
=> SendManyAsync([json], cancellationToken);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task SendManyAsync(IReadOnlyList<string> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>Fuer das Verteilen an alle: ein toter Client darf nicht stoeren.</summary>
|
|
public async Task TrySendManyAsync(IReadOnlyList<string> 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();
|
|
}
|
|
}
|