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,196 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using SwyxTray.Swyx;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Kommandos der Webseite in Aufrufe des <see cref="SwyxClient"/> um
|
||||
/// — jeden davon im UI-Thread, weil dort die COM-Verbindung lebt.
|
||||
/// </summary>
|
||||
internal sealed class CommandExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Obergrenze fuer einen Sprung in den UI-Thread. Ohne sie wuerde eine
|
||||
/// blockierte Nachrichtenschleife (etwa ein haengender COM-Aufruf) die
|
||||
/// Verbindung stillschweigend einfrieren.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly SwyxClient _client;
|
||||
private readonly UiDispatcher _ui;
|
||||
private readonly PluginTabChannel _tabs;
|
||||
|
||||
public CommandExecutor(SwyxClient client, UiDispatcher ui, PluginTabChannel tabs)
|
||||
{
|
||||
_client = client;
|
||||
_ui = ui;
|
||||
_tabs = tabs;
|
||||
}
|
||||
|
||||
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
=> RunAsync(() => _client.Current, cancellationToken);
|
||||
|
||||
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (command.Cmd!.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "ping":
|
||||
return new ResultMessage(command.Id, true);
|
||||
|
||||
case "call":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Dial(command.Number ?? string.Empty), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "answer":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Answer(command.Line), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "hangup":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Hangup(command.Line), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "hold":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "focus":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
case "tabs":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Tabs: await ListTabsAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
case "opentab":
|
||||
return new ResultMessage(command.Id, true,
|
||||
TabId: await OpenTabAsync(command, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
case "closetab":
|
||||
await CloseTabAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return new ResultMessage(command.Id, true);
|
||||
|
||||
case "reconnect":
|
||||
await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new ResultMessage(command.Id, true);
|
||||
|
||||
default:
|
||||
return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
// Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung,
|
||||
// SwyxIt! nicht verbunden.
|
||||
return new ResultMessage(command.Id, false, ex.Message);
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Log.Error($"Kommando '{command.Cmd}' wurde von CLMgr abgewiesen.", ex);
|
||||
return new ResultMessage(command.Id, false, $"SwyxIt! meldet 0x{ex.HResult:X8}.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Log.Error($"Kommando '{command.Cmd}' lief in die Zeitgrenze — der UI-Thread antwortet nicht.");
|
||||
return new ResultMessage(command.Id, false, "Zeitueberschreitung.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aktiviert den zur Anfrage passenden Firefox-Tab ueber das Plugin
|
||||
/// (<c>true</c> = der Tab war schon offen) oder oeffnet die Adresse als
|
||||
/// neuen Tab (<c>false</c>). Ohne verbundenes Plugin scheitert das
|
||||
/// Kommando — einen anderen Weg, eine Seite anzuzeigen, gibt es nicht.
|
||||
/// </summary>
|
||||
private async Task<bool> FocusAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = ValidateUrl(command.Url);
|
||||
var title = command.Title?.Trim();
|
||||
|
||||
// Ein "Aktivieren" kennt der Tab-Kanal nicht — nach vorn kommt ein
|
||||
// vorhandener Tab als Komposition der drei Operationen: Liste holen,
|
||||
// den passenden Tab schliessen und die URL neu oeffnen (ein neuer Tab
|
||||
// ist in Firefox von selbst vorn).
|
||||
if (await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) is not { } openTabs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Kein Firefox-Plugin verbunden oder es antwortet nicht.");
|
||||
}
|
||||
|
||||
var match = openTabs.FirstOrDefault(t =>
|
||||
(!string.IsNullOrEmpty(title)
|
||||
&& (t.Title?.Contains(title, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
|| string.Equals(t.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match?.Id is { } tabId)
|
||||
{
|
||||
await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (await _tabs.TryOpenTabAsync(url, cancellationToken).ConfigureAwait(false) is null)
|
||||
{
|
||||
throw new InvalidOperationException("Das Firefox-Plugin hat den Tab nicht geoeffnet.");
|
||||
}
|
||||
|
||||
return match is not null; // true = der Tab war schon offen
|
||||
}
|
||||
|
||||
/// <summary>Die offenen Tabs — die drei Tab-Kommandos des Hauptports
|
||||
/// reichen die Auftraege unveraendert an das Firefox-Plugin durch.</summary>
|
||||
private async Task<IReadOnlyList<TabInfo>> ListTabsAsync(CancellationToken cancellationToken)
|
||||
=> await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException(
|
||||
"Kein Firefox-Plugin verbunden oder es antwortet nicht.");
|
||||
|
||||
private async Task<int> OpenTabAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
=> await _tabs.TryOpenTabAsync(ValidateUrl(command.Url), cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException(
|
||||
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht geoeffnet.");
|
||||
|
||||
private async Task CloseTabAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.TabId is not { } tabId)
|
||||
{
|
||||
throw new ArgumentException("Feld 'tabId' fehlt.");
|
||||
}
|
||||
|
||||
if (!await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht geschlossen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz
|
||||
/// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen.
|
||||
/// </summary>
|
||||
private static string ValidateUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
throw new ArgumentException("Feld 'url' fehlt.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException("Feld 'url' muss eine absolute http(s)-Adresse sein.");
|
||||
}
|
||||
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
private async Task<T> RunAsync<T>(Func<T> function, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(CommandTimeout);
|
||||
return await _ui.RunAsync(function, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using SwyxTray.Swyx;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket-Zugang der Tray-Anwendung: ein TcpListener auf der Loopback-
|
||||
/// Adresse, ein von Hand beantworteter HTTP-Upgrade und danach die
|
||||
/// WebSocket-Umsetzung der Klassenbibliothek.
|
||||
///
|
||||
/// Absichtlich kein HttpListener: der laeuft ueber http.sys und braucht auch
|
||||
/// fuer 127.0.0.1 eine einmalige URL-Reservierung mit Administratorrechten.
|
||||
/// Die Anwendung soll ohne solche Rechte auskommen.
|
||||
///
|
||||
/// Gebunden wird an 127.0.0.1 und ::1, mit
|
||||
/// <see cref="WebSocketConfig.AllowRemoteAccess"/> stattdessen an alle
|
||||
/// Schnittstellen. Solange es bei Loopback bleibt, ist der Zugang von aussen
|
||||
/// nicht erreichbar und die Windows-Firewall fragt beim Start nichts nach.
|
||||
/// </summary>
|
||||
internal sealed class LocalWebSocketServer : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly WebSocketConfig _config;
|
||||
private readonly PluginTabChannel _tabChannel;
|
||||
private readonly CommandExecutor _executor;
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly List<TcpListener> _listeners = new();
|
||||
private readonly ConcurrentDictionary<int, WebSocketSession> _sessions = new();
|
||||
|
||||
private int _nextSessionId;
|
||||
private string _host = "127.0.0.1";
|
||||
|
||||
public LocalWebSocketServer(WebSocketConfig config, SwyxClient client, UiDispatcher ui)
|
||||
{
|
||||
_config = config;
|
||||
_tabChannel = new PluginTabChannel();
|
||||
_executor = new CommandExecutor(client, ui, _tabChannel);
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
public int ConnectionCount => _sessions.Count;
|
||||
|
||||
public int Port => _config.Port;
|
||||
|
||||
/// <summary>Zweiter Port fuer das Firefox-Plugin; 0 = abgeschaltet.</summary>
|
||||
public int PluginPort => _config.PluginPort;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse samt Token, wie sie eine Seite verwenden kann — bei Zugriff aus
|
||||
/// dem Netz mit der IP-Adresse dieses Rechners statt 127.0.0.1.
|
||||
/// </summary>
|
||||
public string Endpoint => $"ws://{_host}:{_config.Port}/?token={_config.Token}";
|
||||
|
||||
public bool IsRemote => _config.AllowRemoteAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Fragt die offenen Tabs beim Firefox-Plugin ab. <c>null</c>, wenn kein
|
||||
/// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryListTabsAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL.
|
||||
/// <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
|
||||
/// </summary>
|
||||
public Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryOpenTabAsync(url, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id.
|
||||
/// <c>true</c> nur bei bestaetigtem Erfolg.
|
||||
/// </summary>
|
||||
public Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryCloseTabAsync(tabId, cancellationToken);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (!_config.Enabled)
|
||||
{
|
||||
Log.Info("WebSocket-Zugang ist in der Konfiguration abgeschaltet.");
|
||||
return;
|
||||
}
|
||||
|
||||
var remote = _config.AllowRemoteAccess;
|
||||
|
||||
if (!TryListen(remote ? IPAddress.Any : IPAddress.Loopback, _config.Port, required: true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser loesen "localhost" haeufig zuerst nach ::1 auf. Ohne diesen
|
||||
// zweiten Listener scheitert eine Verbindung auf ws://localhost:… ,
|
||||
// waehrend ws://127.0.0.1:… funktioniert — ein schwer zu deutender
|
||||
// Unterschied. Bei IPv6Any gilt dasselbe fuer entfernte Rechner, die
|
||||
// den Namen ueber IPv6 aufloesen.
|
||||
TryListen(remote ? IPAddress.IPv6Any : IPAddress.IPv6Loopback, _config.Port, required: false);
|
||||
|
||||
// Derselbe Dienst noch einmal auf dem Plugin-Port — faellt er aus,
|
||||
// laeuft der Hauptzugang unveraendert weiter.
|
||||
if (_config.PluginPort > 0
|
||||
&& TryListen(remote ? IPAddress.Any : IPAddress.Loopback, _config.PluginPort, required: false))
|
||||
{
|
||||
TryListen(remote ? IPAddress.IPv6Any : IPAddress.IPv6Loopback, _config.PluginPort, required: false);
|
||||
}
|
||||
|
||||
IsRunning = true;
|
||||
|
||||
var addresses = remote ? LocalAddresses() : ["127.0.0.1"];
|
||||
_host = addresses[0];
|
||||
|
||||
var reach = remote
|
||||
? $"aus dem Netz erreichbar unter {string.Join(", ", addresses.Select(a => $"ws://{a}:{_config.Port}/"))}"
|
||||
: $"bereit auf ws://127.0.0.1:{_config.Port}/";
|
||||
|
||||
if (_config.PluginPort > 0)
|
||||
{
|
||||
reach += $" (Firefox-Plugin: Port {_config.PluginPort})";
|
||||
}
|
||||
|
||||
if (WebSocketConfig.SecurityDisabled)
|
||||
{
|
||||
Log.Info($"WebSocket-Zugang {reach} — OHNE PRUEFUNG: Token, Origin und Host werden " +
|
||||
"nicht geprueft. " + (remote
|
||||
? "Jeder im selben Netz kann telefonieren und mitlesen."
|
||||
: "Jede im Browser geoeffnete Seite kann telefonieren und mitlesen.") +
|
||||
" Nicht in diesem Zustand ausliefern.");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"WebSocket-Zugang {reach} " +
|
||||
$"({_config.AllowedOrigins.Count} erlaubte Origin(s), Token in {WebSocketConfig.FilePath}).");
|
||||
|
||||
if (remote)
|
||||
{
|
||||
Log.Info("Zugriff aus dem Netz ist eingeschaltet. Der Zugang haengt damit allein an " +
|
||||
"Token und Origin-Liste. Wird der Port nicht erreicht, fehlt vermutlich die " +
|
||||
$"Freigabe in der Windows-Firewall (eingehend, TCP {_config.Port}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die IPv4-Adressen dieses Rechners — nur fuer die Anzeige, damit im
|
||||
/// Protokoll und in der Zwischenablage steht, was ein zweiter Rechner
|
||||
/// tatsaechlich ansprechen kann.
|
||||
/// </summary>
|
||||
private static string[] LocalAddresses()
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up
|
||||
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||
.Select(u => u.Address)
|
||||
.Where(a => a.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(a))
|
||||
.Select(a => a.ToString())
|
||||
.ToArray();
|
||||
|
||||
return addresses.Length > 0 ? addresses : ["127.0.0.1"];
|
||||
}
|
||||
catch (NetworkInformationException ex)
|
||||
{
|
||||
Log.Debug($"Eigene Adressen nicht ermittelbar: {ex.Message}");
|
||||
return ["127.0.0.1"];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nur der IPv4-Listener des Hauptports ist Bedingung (<paramref name="required"/>);
|
||||
/// alles Weitere — IPv6 und der Plugin-Port — ist Zugabe: faellt es aus,
|
||||
/// laeuft der Zugang ueber die verbleibenden Listener weiter.
|
||||
/// </summary>
|
||||
private bool TryListen(IPAddress address, int port, bool required)
|
||||
{
|
||||
try
|
||||
{
|
||||
var listener = new TcpListener(address, port);
|
||||
listener.Start();
|
||||
_listeners.Add(listener);
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener));
|
||||
return true;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
if (required)
|
||||
{
|
||||
Log.Error($"Port {port} konnte nicht belegt werden — WebSocket-Zugang bleibt aus. " +
|
||||
$"Belegt ihn ein anderes Programm? Anderen Port in {WebSocketConfig.FilePath} eintragen.", ex);
|
||||
}
|
||||
else if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
Log.Error($"Port {port} konnte nicht belegt werden — dieser Zugang bleibt aus, " +
|
||||
"der uebrige WebSocket-Zugang laeuft weiter.", ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"IPv6 auf Port {port} nicht verfuegbar ({ex.SocketErrorCode}); " +
|
||||
"es wird nur ueber IPv4 bedient.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener)
|
||||
{
|
||||
while (!_shutdown.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(_shutdown.Token).ConfigureAwait(false);
|
||||
_ = Task.Run(() => HandleAsync(client));
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
return; // Beenden oder Listener geschlossen.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Fehler beim Annehmen einer WebSocket-Verbindung.", ex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(TcpClient client)
|
||||
{
|
||||
var remote = client.Client.RemoteEndPoint?.ToString() ?? "unbekannt";
|
||||
|
||||
// Am lokalen Port haengt die Rolle der Verbindung: der Plugin-Port
|
||||
// traegt nur die Tab-Verwaltung, alles andere den vollen Dienst.
|
||||
var isPlugin = _config.PluginPort > 0
|
||||
&& client.Client.LocalEndPoint is IPEndPoint local
|
||||
&& local.Port == _config.PluginPort;
|
||||
|
||||
using (client)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
var stream = client.GetStream();
|
||||
|
||||
using var handshake = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
|
||||
handshake.CancelAfter(HandshakeTimeout);
|
||||
|
||||
var request = await WebSocketHandshake.ReadAsync(stream, handshake.Token).ConfigureAwait(false);
|
||||
if (request is null)
|
||||
{
|
||||
Log.Debug($"Unvollstaendige Anfrage von {remote} verworfen.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sessions.Count >= _config.MaxConnections)
|
||||
{
|
||||
await Reject(stream, 503, "Zu viele Verbindungen.", remote, handshake.Token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WebSocketHandshake.Validate(request, _config) is { } refusal)
|
||||
{
|
||||
await Reject(stream, refusal.Status, refusal.Reason, remote, handshake.Token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await WebSocketHandshake.AcceptAsync(stream, request, handshake.Token).ConfigureAwait(false);
|
||||
await ServeAsync(stream, request, remote, isPlugin).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Beenden oder Zeitgrenze beim Handshake — nichts zu melden.
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException
|
||||
or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
Log.Debug($"Verbindung {remote} abgebrochen: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Fehler in der Verbindung {remote}.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(Stream stream, WebSocketHandshake.Request request, string remote, bool isPlugin)
|
||||
{
|
||||
// Ab hier uebernimmt die Klassenbibliothek: Rahmen, Maskierung,
|
||||
// Ping/Pong und der Schliessvorgang.
|
||||
var socket = WebSocket.CreateFromStream(stream, new WebSocketCreationOptions
|
||||
{
|
||||
IsServer = true,
|
||||
SubProtocol = request.SubProtocol,
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(15)
|
||||
});
|
||||
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
using var session = new WebSocketSession(id, socket, _executor, _tabChannel, request.Origin, isPlugin);
|
||||
_sessions[id] = session;
|
||||
if (isPlugin)
|
||||
{
|
||||
_tabChannel.Register(session);
|
||||
}
|
||||
|
||||
Log.Info($"WebSocket {id} verbunden ({remote}, Origin {session.Origin}" +
|
||||
(isPlugin ? ", Plugin-Port)." : ")."));
|
||||
|
||||
try
|
||||
{
|
||||
await session.RunAsync(_shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (isPlugin)
|
||||
{
|
||||
_tabChannel.Unregister(session);
|
||||
}
|
||||
|
||||
_sessions.TryRemove(id, out _);
|
||||
Log.Info($"WebSocket {id} getrennt.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Reject(
|
||||
Stream stream, int status, string reason, string remote, CancellationToken cancellationToken)
|
||||
{
|
||||
Log.Info($"WebSocket-Anfrage von {remote} abgewiesen ({status}): {reason}");
|
||||
await WebSocketHandshake.RejectAsync(stream, status, reason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
|
||||
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
|
||||
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
|
||||
/// Zustand beim Eintreffen eines Ereignisses schon aktuell ist. Wird aus
|
||||
/// dem UI-Thread aufgerufen: das Serialisieren geschieht einmal und sofort,
|
||||
/// das Senden laeuft danach ohne den UI-Thread aufzuhalten.
|
||||
/// </summary>
|
||||
public void Broadcast(SwyxSnapshot previous, SwyxSnapshot current)
|
||||
{
|
||||
if (_sessions.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var messages = new List<string> { Protocol.Serialize(SnapshotMessage.From(current)) };
|
||||
foreach (var callEvent in CallEventMessage.Diff(previous, current))
|
||||
{
|
||||
messages.Add(Protocol.Serialize(callEvent));
|
||||
}
|
||||
|
||||
foreach (var session in _sessions.Values)
|
||||
{
|
||||
// Der Plugin-Port traegt nur die Tab-Verwaltung — Zustand und
|
||||
// Anruf-Ereignisse gehen ausschliesslich an den Hauptport.
|
||||
if (session.IsPlugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = session.TrySendManyAsync(messages, _shutdown.Token);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
|
||||
foreach (var listener in _listeners)
|
||||
{
|
||||
try
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Beim Herunterfahren ohne Belang.
|
||||
}
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
|
||||
foreach (var session in _sessions.Values)
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
_sessions.Clear();
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Der Tab-Verwaltungskanal zum Firefox-Plugin: SwyxTray schickt Auftraege
|
||||
/// (<see cref="TabRequestMessage"/>), das Plugin antwortet mit
|
||||
/// <c>tabresult</c>. Mehr laeuft auf dem Plugin-Port nicht — Telefonie gibt
|
||||
/// es nur auf dem Hauptport.
|
||||
///
|
||||
/// Die Richtung ist hier umgekehrt zum uebrigen Protokoll: der Server fragt,
|
||||
/// der Client antwortet. Anfrage und Antwort werden ueber eine eigene,
|
||||
/// serverseitig vergebene Id einander zugeordnet.
|
||||
/// </summary>
|
||||
internal sealed class PluginTabChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// So lange darf das Plugin brauchen. Danach faellt <c>focus</c> auf den
|
||||
/// Browserstart zurueck, statt die Kommando-Antwort aufzuhalten.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan AnswerTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly ConcurrentDictionary<int, WebSocketSession> _plugins = new();
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<ClientCommand>> _pending = new();
|
||||
private int _nextRequestId;
|
||||
|
||||
public void Register(WebSocketSession session) => _plugins[session.Id] = session;
|
||||
|
||||
public void Unregister(WebSocketSession session) => _plugins.TryRemove(session.Id, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Die offenen Tabs, oder <c>null</c>, wenn kein Plugin verbunden ist,
|
||||
/// keine Antwort kommt oder das Plugin den Auftrag ablehnt.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
|
||||
=> await RequestAsync(id => new TabRequestMessage(id, "list"), cancellationToken)
|
||||
.ConfigureAwait(false) is { Ok: true } answer
|
||||
? answer.Tabs ?? []
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Oeffnet einen neuen Tab mit der URL. Liefert die Id des neuen Tabs,
|
||||
/// oder <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
|
||||
/// </summary>
|
||||
public async Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
|
||||
=> await RequestAsync(id => new TabRequestMessage(id, "open", Url: url), cancellationToken)
|
||||
.ConfigureAwait(false) is { Ok: true } answer
|
||||
? answer.TabId ?? -1
|
||||
: null;
|
||||
|
||||
/// <summary>Schliesst den Tab. <c>true</c> nur bei bestaetigtem Erfolg.</summary>
|
||||
public async Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
|
||||
=> (await RequestAsync(id => new TabRequestMessage(id, "close", TabId: tabId), cancellationToken)
|
||||
.ConfigureAwait(false))?.Ok == true;
|
||||
|
||||
private async Task<ClientCommand?> RequestAsync(
|
||||
Func<int, TabRequestMessage> request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Bei mehreren Verbindungen (etwa nach einem Firefox-Neustart, dessen
|
||||
// alte Session noch nicht ausgelaufen ist) ist die juengste die richtige.
|
||||
var session = _plugins.Values.MaxBy(s => s.Id);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = Interlocked.Increment(ref _nextRequestId);
|
||||
var pending = new TaskCompletionSource<ClientCommand>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = pending;
|
||||
|
||||
try
|
||||
{
|
||||
await session.TrySendManyAsync(
|
||||
[Protocol.Serialize(request(id))], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var finished = await Task.WhenAny(
|
||||
pending.Task, Task.Delay(AnswerTimeout, cancellationToken)).ConfigureAwait(false);
|
||||
if (finished != pending.Task)
|
||||
{
|
||||
Log.Info($"Tab-Anfrage {id} blieb ohne Antwort vom Firefox-Plugin.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await pending.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ordnet ein <c>tabresult</c> des Plugins der wartenden Anfrage zu.</summary>
|
||||
public bool TryComplete(ClientCommand tabResult)
|
||||
=> tabResult.Id is { } id
|
||||
&& _pending.TryRemove(id, out var pending)
|
||||
&& pending.TrySetResult(tabResult);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SwyxTray.Swyx;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Die ueber den WebSocket ausgetauschten Nachrichten. Alles ist UTF-8-JSON,
|
||||
/// eine Nachricht je WebSocket-Frame.
|
||||
///
|
||||
/// Leitungen werden nach aussen 1-basiert gezaehlt ("Leitung 1"), so wie sie im
|
||||
/// Kontextmenue und in SwyxIt! erscheinen — intern sind sie 0-basiert.
|
||||
/// </summary>
|
||||
internal static class Protocol
|
||||
{
|
||||
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T message) => JsonSerializer.Serialize(message, Json);
|
||||
}
|
||||
|
||||
/// <summary>Nachricht der Webseite an die App.</summary>
|
||||
internal sealed class ClientCommand
|
||||
{
|
||||
/// <summary>Frei waehlbar; wird in der Antwort zurueckgegeben.</summary>
|
||||
public int? Id { get; set; }
|
||||
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>call</c>.</summary>
|
||||
public string? Number { get; set; }
|
||||
|
||||
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
|
||||
public int? Line { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>focus</c>: gesuchter Fenstertitel.</summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bei <c>focus</c> und <c>opentab</c>: die zu oeffnende Adresse.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>tabresult</c> (Plugin-Port): Auftrag ausgefuehrt?</summary>
|
||||
public bool? Ok { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bei <c>closetab</c> (Hauptport): der zu schliessende Tab. Bei
|
||||
/// <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen Tabs.
|
||||
/// </summary>
|
||||
public int? TabId { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>tabresult</c> auf <c>list</c>: die offenen Tabs.</summary>
|
||||
public List<TabInfo>? Tabs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Ein offener Tab, wie ihn das Plugin bei <c>list</c> meldet.</summary>
|
||||
internal sealed class TabInfo
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public bool? Active { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Begruessung unmittelbar nach dem Verbindungsaufbau.</summary>
|
||||
internal sealed record HelloMessage(string App, string Version, int Protocol, int Session)
|
||||
{
|
||||
public string Type => "hello";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
|
||||
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
|
||||
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
|
||||
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>.
|
||||
/// </summary>
|
||||
internal sealed record ResultMessage(
|
||||
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
|
||||
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null)
|
||||
{
|
||||
public string Type => "result";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen:
|
||||
/// <c>list</c> (offene Tabs melden), <c>open</c> (neuen Tab mit <c>Url</c>
|
||||
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen). Das Plugin antwortet
|
||||
/// jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei <c>list</c>
|
||||
/// zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>. Die Id
|
||||
/// stammt hier vom Server, nicht von einer Webseite.
|
||||
/// </summary>
|
||||
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null)
|
||||
{
|
||||
public string Type => "tab";
|
||||
}
|
||||
|
||||
internal sealed record LineMessage(
|
||||
int Line,
|
||||
string State,
|
||||
int StateCode,
|
||||
string StateText,
|
||||
string Peer,
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
bool Busy,
|
||||
bool Selected);
|
||||
|
||||
/// <summary>
|
||||
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
|
||||
/// Zustaende. Wird zusaetzlich zum snapshot gesendet, damit eine Seite auf
|
||||
/// eingehende Rufe reagieren kann, ohne selbst Zustaende zu vergleichen.
|
||||
///
|
||||
/// <c>Event</c> ist <c>incoming</c> (Leitung beginnt zu klingeln),
|
||||
/// <c>outgoing</c> (Wahl beginnt), <c>connected</c> (Gespraech steht) oder
|
||||
/// <c>ended</c> (Leitung wieder frei). Bei <c>connected</c> steht in
|
||||
/// <c>Direction</c>, ob der Ruf ein- oder ausgehend war, sofern erkennbar;
|
||||
/// bei <c>ended</c> stammen die Peer-Angaben aus dem letzten belegten Zustand.
|
||||
/// </summary>
|
||||
internal sealed record CallEventMessage(
|
||||
string Event,
|
||||
int Line,
|
||||
string Peer,
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
string? Direction = null)
|
||||
{
|
||||
public string Type => "call";
|
||||
|
||||
public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current)
|
||||
{
|
||||
var events = new List<CallEventMessage>();
|
||||
var before = previous.Lines.ToDictionary(l => l.Index);
|
||||
|
||||
foreach (var line in current.Lines)
|
||||
{
|
||||
before.TryGetValue(line.Index, out var prev);
|
||||
var wasRinging = prev?.State.IsRinging() ?? false;
|
||||
var wasDialing = prev?.State.IsDialing() ?? false;
|
||||
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold());
|
||||
|
||||
if (line.State.IsRinging() && !wasRinging)
|
||||
{
|
||||
events.Add(From("incoming", line));
|
||||
}
|
||||
else if (line.State.IsDialing() && !wasDialing && !wasRinging && !wasTalking)
|
||||
{
|
||||
events.Add(From("outgoing", line));
|
||||
}
|
||||
else if (line.State.IsActive() && !wasTalking)
|
||||
{
|
||||
// Halten und Zurueckholen ist kein neues Gespraech; der Wechsel
|
||||
// aus Klingeln oder Wahl dagegen schon.
|
||||
events.Add(From("connected", line,
|
||||
wasRinging ? "incoming" : wasDialing ? "outgoing" : null));
|
||||
}
|
||||
}
|
||||
|
||||
var after = current.Lines.ToDictionary(l => l.Index);
|
||||
foreach (var prev in previous.Lines.Where(l => l.IsBusy))
|
||||
{
|
||||
if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy)
|
||||
{
|
||||
events.Add(From("ended", prev));
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null)
|
||||
=> new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vollstaendiger Zustand. Wird nach dem hello, bei jeder Aenderung und auf
|
||||
/// <c>status</c> gesendet — die Webseite muss also nie einen Zustand mitfuehren.
|
||||
/// </summary>
|
||||
internal sealed record SnapshotMessage(
|
||||
bool Connected,
|
||||
bool ServerUp,
|
||||
string Overall,
|
||||
string StatusText,
|
||||
string User,
|
||||
string Server,
|
||||
IReadOnlyList<LineMessage> Lines)
|
||||
{
|
||||
public string Type => "snapshot";
|
||||
|
||||
public static SnapshotMessage From(SwyxSnapshot snapshot) => new(
|
||||
Connected: snapshot.IsConnected,
|
||||
ServerUp: snapshot.IsServerUp,
|
||||
Overall: snapshot.Overall.ToString(),
|
||||
StatusText: snapshot.StatusText,
|
||||
User: snapshot.UserName,
|
||||
Server: snapshot.ServerName,
|
||||
Lines: snapshot.Lines.Select(l => new LineMessage(
|
||||
Line: l.Index + 1,
|
||||
State: l.State.ToString(),
|
||||
StateCode: (int)l.State,
|
||||
StateText: l.State.ToDisplayText(),
|
||||
Peer: l.PeerDisplayLong,
|
||||
PeerNumber: l.PeerNumber,
|
||||
PeerName: l.PeerName,
|
||||
Busy: l.IsBusy,
|
||||
Selected: l.IsSelected)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Schleust Arbeit aus den Netzwerk-Threads in den UI-Thread.
|
||||
///
|
||||
/// Notwendig, weil CLMgr ein STA-COM-Server ist und die Anwendung alle
|
||||
/// COM-Zugriffe im WinForms-Thread buendelt (siehe SwyxClient). Ein Aufruf aus
|
||||
/// einem Thread-Pool-Thread wuerde COM zwingen, den Aufruf selbst zu
|
||||
/// marshallen — mit einer eigenen, hier nicht vorhandenen Nachrichtenschleife.
|
||||
///
|
||||
/// Der SynchronizationContext wird bewusst erst innerhalb der laufenden
|
||||
/// Nachrichtenschleife eingesammelt; vorher steht der WinForms-Kontext noch
|
||||
/// nicht.
|
||||
/// </summary>
|
||||
internal sealed class UiDispatcher
|
||||
{
|
||||
private readonly SynchronizationContext _context;
|
||||
|
||||
private UiDispatcher(SynchronizationContext context) => _context = context;
|
||||
|
||||
/// <summary>Liefert <c>null</c>, wenn kein UI-Kontext installiert ist.</summary>
|
||||
public static UiDispatcher? Capture()
|
||||
=> SynchronizationContext.Current is { } context ? new UiDispatcher(context) : null;
|
||||
|
||||
public async Task<T> RunAsync<T>(Func<T> function, CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Ohne die Registrierung bliebe der Aufrufer haengen, falls die
|
||||
// Nachrichtenschleife beim Beenden nicht mehr zum Zug kommt.
|
||||
await using var registration = cancellationToken.Register(
|
||||
() => completion.TrySetCanceled(cancellationToken));
|
||||
|
||||
_context.Post(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
completion.TrySetResult(function());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.TrySetException(ex);
|
||||
}
|
||||
}, null);
|
||||
|
||||
return await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen des WebSocket-Zugangs, abgelegt neben dem Protokoll unter
|
||||
/// %LOCALAPPDATA%\SwyxTray\websocket.json.
|
||||
///
|
||||
/// Beim ersten Start wird die Datei mit einem zufaelligen Token erzeugt. Die
|
||||
/// Liste der erlaubten Origins bleibt dabei leer — ohne Eintrag kann sich
|
||||
/// keine Webseite verbinden. Das ist Absicht: ein offener Port, an dem jede
|
||||
/// beliebige Seite Anrufe ausloesen kann, waere die schlechtere Voreinstellung.
|
||||
/// </summary>
|
||||
internal sealed class WebSocketConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Version des JSON-Protokolls, wird im hello-Paket gemeldet.
|
||||
/// Version 2: zusaetzliche <c>call</c>-Ereignisse (incoming/outgoing/
|
||||
/// connected/ended) nach jedem snapshot.
|
||||
/// Version 3: Kommando <c>focus</c> (Fenster in den Vordergrund holen,
|
||||
/// ersatzweise URL im Standardbrowser oeffnen).
|
||||
/// Version 4: Plugin-Port als reiner Tab-Verwaltungskanal
|
||||
/// (<c>tab</c>-Auftraege an das Firefox-Plugin, <c>tabresult</c> zurueck).
|
||||
/// Version 5: der eine Tab-Auftrag ist in die drei Aktionen <c>list</c>,
|
||||
/// <c>open</c> und <c>close</c> aufgeteilt.
|
||||
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
|
||||
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
|
||||
/// </summary>
|
||||
public const int ProtocolVersion = 6;
|
||||
|
||||
/// <summary>
|
||||
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
|
||||
/// in JEDEM Build, nicht nur im Debug-Build. Wer den Port erreicht, kann
|
||||
/// telefonieren und mitlesen; zusammen mit
|
||||
/// <see cref="AllowRemoteAccess"/> ist das jeder im selben Netz.
|
||||
///
|
||||
/// Das ist eine bewusste Entscheidung fuer die Erprobung. Zum
|
||||
/// Wiedereinschalten die folgende Zuweisung durch
|
||||
///
|
||||
/// public static readonly bool SecurityDisabled =
|
||||
/// #if DEBUG
|
||||
/// true;
|
||||
/// #else
|
||||
/// false;
|
||||
/// #endif
|
||||
///
|
||||
/// ersetzen — dann prueft der Release-Build wieder alles, waehrend sich der
|
||||
/// Debug-Build ohne Einrichtung erproben laesst. Die dazugehoerigen
|
||||
/// Pruefungen stehen unveraendert in
|
||||
/// <see cref="WebSocketHandshake.Validate"/>, es ist also nur diese eine
|
||||
/// Zeile.
|
||||
///
|
||||
/// Kein <c>const</c>, sonst meldet der Compiler die abhaengigen Zweige als
|
||||
/// unerreichbar.
|
||||
/// </summary>
|
||||
public static readonly bool SecurityDisabled = true;
|
||||
|
||||
public static string FilePath { get; } = Path.Combine(Log.Directory, "websocket.json");
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public int Port { get; set; } = 17654;
|
||||
|
||||
/// <summary>
|
||||
/// Zweiter Port, auf dem derselbe Dienst zusaetzlich hoert — gedacht fuer
|
||||
/// das Firefox-Plugin (SwyxFFPlugin), damit Plugin und normale Seiten
|
||||
/// sich nicht denselben Eintrag teilen muessen. 0 schaltet ihn ab.
|
||||
/// </summary>
|
||||
public int PluginPort { get; set; } = 17655;
|
||||
|
||||
/// <summary>Gemeinsames Geheimnis. Leer = Zugang gesperrt.</summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Erlaubte Werte des Origin-Headers, z. B. "https://crm.example.local".
|
||||
/// Ein Eintrag "*" laesst jede Seite zu und wird beim Start bemaengelt.
|
||||
/// </summary>
|
||||
public List<string> AllowedOrigins { get; set; } = new();
|
||||
|
||||
public int MaxConnections { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Voreingestellt hoert die App nur auf 127.0.0.1 und ::1. Auf <c>true</c>
|
||||
/// bindet sie an alle Schnittstellen und ist damit aus dem lokalen Netz
|
||||
/// erreichbar — gedacht fuer den Test von einem zweiten Rechner aus.
|
||||
///
|
||||
/// Damit faellt die Host-Pruefung weg (die App kann nicht wissen, unter
|
||||
/// welchem Namen sie angesprochen wird), und der Zugang haengt allein an
|
||||
/// Token und Origin-Liste. Im Debug-Build, in dem auch die nicht geprueft
|
||||
/// werden, kann dann jeder im selben Netz Anrufe ausloesen.
|
||||
/// </summary>
|
||||
public bool AllowRemoteAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Laedt die Konfiguration; legt sie beim ersten Start an. Bei einer
|
||||
/// defekten Datei wird bewusst <c>null</c> geliefert und der Server nicht
|
||||
/// gestartet: ein neu erzeugtes Token wuerde alle Clients aussperren, ohne
|
||||
/// dass jemand die Ursache sieht.
|
||||
/// </summary>
|
||||
public static WebSocketConfig? Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
var created = new WebSocketConfig { Token = NewToken() };
|
||||
Directory.CreateDirectory(Log.Directory);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(created, WriteOptions), Encoding.UTF8);
|
||||
Log.Info($"WebSocket-Konfiguration angelegt: {FilePath}. Es ist noch kein Origin " +
|
||||
"eingetragen, deshalb wird jede Verbindung aus dem Browser abgewiesen.");
|
||||
return created;
|
||||
}
|
||||
|
||||
var config = JsonSerializer.Deserialize<WebSocketConfig>(File.ReadAllText(FilePath), ReadOptions);
|
||||
if (config is null)
|
||||
{
|
||||
Log.Error($"WebSocket-Konfiguration {FilePath} ist leer — Server wird nicht gestartet.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return config.Validate() ? config : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Error($"WebSocket-Konfiguration {FilePath} konnte nicht gelesen werden — " +
|
||||
"Server wird nicht gestartet.", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool Validate()
|
||||
{
|
||||
if (Port is < 1 or > 65535)
|
||||
{
|
||||
Log.Error($"Ungueltiger Port {Port} in {FilePath} — Server wird nicht gestartet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PluginPort != 0 && (PluginPort is < 1 or > 65535 || PluginPort == Port))
|
||||
{
|
||||
Log.Error($"Ungueltiger PluginPort {PluginPort} in {FilePath} — " +
|
||||
"der Plugin-Port bleibt aus, der uebrige Zugang startet normal.");
|
||||
PluginPort = 0;
|
||||
}
|
||||
|
||||
if (MaxConnections < 1)
|
||||
{
|
||||
MaxConnections = 1;
|
||||
}
|
||||
|
||||
// Solange die Pruefung abgeschaltet ist, darf ein fehlendes oder kurzes
|
||||
// Token den Start nicht verhindern.
|
||||
if (Token.Length < 16 && !SecurityDisabled)
|
||||
{
|
||||
Log.Error("Das Token in der WebSocket-Konfiguration fehlt oder ist zu kurz " +
|
||||
"(mindestens 16 Zeichen) — Server wird nicht gestartet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SecurityDisabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AllowedOrigins.Any(o => o.Trim() == "*"))
|
||||
{
|
||||
Log.Error("In der WebSocket-Konfiguration steht '*' als erlaubter Origin. Damit kann " +
|
||||
"jede im Browser geoeffnete Seite Anrufe ausloesen — nur zum Erproben nutzen.");
|
||||
}
|
||||
else if (AllowedOrigins.Count == 0)
|
||||
{
|
||||
Log.Info("Es ist kein Origin eingetragen; Verbindungen aus dem Browser werden " +
|
||||
$"abgewiesen. Erlaubte Origins in {FilePath} eintragen.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft den Origin des Handshakes. Gross-/Kleinschreibung und ein
|
||||
/// abschliessender Schraegstrich werden ignoriert, weil Browser den Header
|
||||
/// ohne Pfad senden, Konfigurationen ihn aber oft mit Schraegstrich enthalten.
|
||||
/// </summary>
|
||||
public bool IsOriginAllowed(string origin)
|
||||
=> AllowedOrigins.Any(allowed =>
|
||||
{
|
||||
var trimmed = allowed.Trim();
|
||||
return trimmed == "*"
|
||||
|| string.Equals(trimmed.TrimEnd('/'), origin.TrimEnd('/'), StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
/// <summary>Zeitkonstanter Vergleich — das Token ist ein Geheimnis.</summary>
|
||||
public bool IsTokenValid(string? candidate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(candidate) || Token.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = Encoding.UTF8.GetBytes(Token);
|
||||
var actual = Encoding.UTF8.GetBytes(candidate);
|
||||
return CryptographicOperations.FixedTimeEquals(expected, actual);
|
||||
}
|
||||
|
||||
private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Der HTTP-Teil des WebSocket-Aufbaus (RFC 6455, Abschnitt 4.2). Das ist der
|
||||
/// einzige Teil des Protokolls, der von Hand entsteht — Rahmen, Maskierung,
|
||||
/// Ping/Pong und der Schliessvorgang kommen danach von
|
||||
/// <see cref="System.Net.WebSockets.WebSocket.CreateFromStream"/>.
|
||||
/// </summary>
|
||||
internal static class WebSocketHandshake
|
||||
{
|
||||
/// <summary>Feste GUID aus RFC 6455 zur Bildung des Accept-Werts.</summary>
|
||||
private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
private const int MaxHeaderBytes = 16 * 1024;
|
||||
|
||||
public const string SubProtocol = "swyxtray.v1";
|
||||
|
||||
/// <summary>Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding.</summary>
|
||||
private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"];
|
||||
|
||||
internal sealed record Request(
|
||||
string Method,
|
||||
string Target,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string? Token,
|
||||
string Origin,
|
||||
string? SubProtocol);
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Handshake-Anfrage. Bewusst byteweise bis zur Leerzeile: alles
|
||||
/// dahinter gehoert bereits dem WebSocket, und ein zu grosszuegiges Lesen
|
||||
/// wuerde die ersten Frames verschlucken.
|
||||
/// </summary>
|
||||
public static async Task<Request?> ReadAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[1];
|
||||
var raw = new List<byte>(1024);
|
||||
|
||||
while (raw.Count < MaxHeaderBytes)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
raw.Add(buffer[0]);
|
||||
|
||||
if (raw.Count >= 4 &&
|
||||
raw[^4] == (byte)'\r' && raw[^3] == (byte)'\n' &&
|
||||
raw[^2] == (byte)'\r' && raw[^1] == (byte)'\n')
|
||||
{
|
||||
return Parse(Encoding.ASCII.GetString(raw.ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Request? Parse(string text)
|
||||
{
|
||||
var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var requestLine = lines[0].Split(' ');
|
||||
if (requestLine.Length < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var separator = line.IndexOf(':');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = line[..separator].Trim();
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
|
||||
// Mehrfach gesendete Header zusammenfassen, wie in HTTP vorgesehen.
|
||||
headers[name] = headers.TryGetValue(name, out var existing)
|
||||
? $"{existing}, {value}"
|
||||
: value;
|
||||
}
|
||||
|
||||
var offered = headers.GetValueOrDefault("Sec-WebSocket-Protocol", string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return new Request(
|
||||
Method: requestLine[0],
|
||||
Target: requestLine[1],
|
||||
Headers: headers,
|
||||
Token: TokenFrom(requestLine[1], offered),
|
||||
Origin: headers.GetValueOrDefault("Origin", string.Empty),
|
||||
SubProtocol: offered.Contains(SubProtocol) ? SubProtocol : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Token darf in der Abfragezeichenfolge (<c>?token=…</c>) oder als
|
||||
/// Unterprotokoll <c>token.…</c> kommen. Die zweite Form ist fuer Seiten
|
||||
/// nuetzlich, die das Geheimnis nicht in einer URL stehen haben wollen —
|
||||
/// die WebSocket-API des Browsers kennt keine eigenen Header.
|
||||
/// </summary>
|
||||
private static string? TokenFrom(string target, IEnumerable<string> offeredProtocols)
|
||||
{
|
||||
var fromProtocol = offeredProtocols
|
||||
.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal))?["token.".Length..];
|
||||
|
||||
if (!string.IsNullOrEmpty(fromProtocol))
|
||||
{
|
||||
return fromProtocol;
|
||||
}
|
||||
|
||||
var query = target.IndexOf('?');
|
||||
if (query < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in target[(query + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equals = pair.IndexOf('=');
|
||||
if (equals > 0 && pair[..equals].Equals("token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Uri.UnescapeDataString(pair[(equals + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft die Anfrage. Liefert <c>null</c>, wenn alles passt, sonst
|
||||
/// HTTP-Status und Begruendung fuer die Absage.
|
||||
/// </summary>
|
||||
public static (int Status, string Reason)? Validate(Request request, WebSocketConfig config)
|
||||
{
|
||||
if (!request.Method.Equals("GET", StringComparison.Ordinal))
|
||||
{
|
||||
return (405, "Nur GET.");
|
||||
}
|
||||
|
||||
if (!Contains(request.Headers.GetValueOrDefault("Upgrade"), "websocket") ||
|
||||
!Contains(request.Headers.GetValueOrDefault("Connection"), "upgrade"))
|
||||
{
|
||||
return (400, "Kein WebSocket-Upgrade.");
|
||||
}
|
||||
|
||||
if (request.Headers.GetValueOrDefault("Sec-WebSocket-Version") != "13")
|
||||
{
|
||||
return (426, "Es wird nur Sec-WebSocket-Version 13 unterstuetzt.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.Headers.GetValueOrDefault("Sec-WebSocket-Key")))
|
||||
{
|
||||
return (400, "Sec-WebSocket-Key fehlt.");
|
||||
}
|
||||
|
||||
// Bis hierher ging es nur um ein wohlgeformtes Upgrade; alles Weitere
|
||||
// sind die Zugriffsschranken — und die sind zur Zeit abgeschaltet,
|
||||
// siehe WebSocketConfig.SecurityDisabled.
|
||||
if (WebSocketConfig.SecurityDisabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ohne diese Pruefung koennte eine Seite einen eigenen Namen auf
|
||||
// 127.0.0.1 zeigen lassen und so aus ihrem Ursprung heraus verbinden.
|
||||
// Bei Zugriff aus dem Netz entfaellt sie: welchen Namen oder welche
|
||||
// Adresse ein entfernter Rechner verwendet, kann die App nicht wissen.
|
||||
if (!config.AllowRemoteAccess)
|
||||
{
|
||||
var raw = request.Headers.GetValueOrDefault("Host", string.Empty);
|
||||
var host = raw.StartsWith('[') ? "[::1]" : raw.Split(':')[0];
|
||||
|
||||
if (!LoopbackHosts.Contains(host, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return (400, "Unerwarteter Host — erlaubt ist nur 127.0.0.1 bzw. localhost.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.IsTokenValid(request.Token))
|
||||
{
|
||||
return (403, "Token fehlt oder ist falsch.");
|
||||
}
|
||||
|
||||
// Fehlt der Origin, ist der Aufrufer kein Browser. Das ist zulaessig,
|
||||
// weil das Token bereits nachgewiesen wurde; eine Webseite kann den
|
||||
// Header dagegen nicht weglassen, ihr Ursprung wird also stets geprueft.
|
||||
if (request.Origin.Length > 0 && !config.IsOriginAllowed(request.Origin))
|
||||
{
|
||||
return (403, $"Origin '{request.Origin}' ist nicht freigegeben.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Task AcceptAsync(Stream stream, Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.Headers["Sec-WebSocket-Key"];
|
||||
var accept = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + AcceptGuid)));
|
||||
|
||||
var response = new StringBuilder()
|
||||
.Append("HTTP/1.1 101 Switching Protocols\r\n")
|
||||
.Append("Upgrade: websocket\r\n")
|
||||
.Append("Connection: Upgrade\r\n")
|
||||
.Append($"Sec-WebSocket-Accept: {accept}\r\n");
|
||||
|
||||
if (request.SubProtocol is not null)
|
||||
{
|
||||
response.Append($"Sec-WebSocket-Protocol: {request.SubProtocol}\r\n");
|
||||
}
|
||||
|
||||
return WriteAsync(stream, response.Append("\r\n").ToString(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absage mit Klartext im Rumpf. Der Browser zeigt davon zwar nur den
|
||||
/// Status, aber im Protokoll der App und in einem Netzwerkmitschnitt steht
|
||||
/// damit der Grund — sonst bleibt nur ein wortloses Scheitern.
|
||||
/// </summary>
|
||||
public static Task RejectAsync(Stream stream, int status, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes(reason);
|
||||
var head =
|
||||
$"HTTP/1.1 {status} {StatusText(status)}\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
$"Content-Length: {body.Length}\r\n" +
|
||||
"Connection: close\r\n\r\n";
|
||||
|
||||
return WriteAsync(stream, head + reason, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(Stream stream, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool Contains(string? headerValue, string token)
|
||||
=> headerValue is not null && headerValue.Contains(token, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string StatusText(int status) => status switch
|
||||
{
|
||||
400 => "Bad Request",
|
||||
403 => "Forbidden",
|
||||
405 => "Method Not Allowed",
|
||||
426 => "Upgrade Required",
|
||||
503 => "Service Unavailable",
|
||||
_ => "Error"
|
||||
};
|
||||
}
|
||||
@@ -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