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:
2026-08-21 11:33:52 +02:00
commit 78b17a1350
28 changed files with 4396 additions and 0 deletions
+196
View File
@@ -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);
}
}
+398
View File
@@ -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;
}
}
+98
View File
@@ -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);
}
+209
View File
@@ -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());
}
+48
View File
@@ -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);
}
}
+218
View File
@@ -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));
}
+261
View File
@@ -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"
};
}
+247
View File
@@ -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();
}
}