SwyxTray und Firefox-Plugin in das Repository aufnehmen
Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang, Firefox-Erweiterung und Browser-Beispielclient.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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; die Quittung
|
||||
// danach haelt den Ablauf fuer den Client einheitlich.
|
||||
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SendSnapshotAsync(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>
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user