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,67 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SwyxTray;
|
||||
|
||||
/// <summary>
|
||||
/// Schlankes Dateiprotokoll unter %LOCALAPPDATA%\SwyxTray\. Bewusst ohne
|
||||
/// Framework: die App hat kein Fenster, ein Protokoll ist deshalb das einzige
|
||||
/// Mittel zur Fehlersuche.
|
||||
/// </summary>
|
||||
internal static class Log
|
||||
{
|
||||
private const long MaxBytes = 1024 * 1024;
|
||||
private static readonly object Gate = new();
|
||||
|
||||
public static bool VerboseEnabled { get; set; }
|
||||
|
||||
public static string Directory { get; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"SwyxTray");
|
||||
|
||||
public static string FilePath { get; } = Path.Combine(Directory, "swyxtray.log");
|
||||
|
||||
public static void Debug(string message)
|
||||
{
|
||||
if (VerboseEnabled)
|
||||
{
|
||||
Write("DBG", message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Info(string message) => Write("INF", message);
|
||||
|
||||
public static void Error(string message, Exception? ex = null)
|
||||
=> Write("ERR", ex is null ? message : $"{message} :: {ex}");
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
RollIfTooLarge();
|
||||
File.AppendAllText(FilePath, line, Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Ein fehlschlagendes Protokoll darf die App nicht beenden.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RollIfTooLarge()
|
||||
{
|
||||
var info = new FileInfo(FilePath);
|
||||
if (!info.Exists || info.Length < MaxBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var backup = FilePath + ".1";
|
||||
File.Delete(backup);
|
||||
File.Move(FilePath, backup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SwyxTray;
|
||||
|
||||
internal static class NativeMethods
|
||||
{
|
||||
/// <summary>
|
||||
/// Gibt ein von <see cref="System.Drawing.Bitmap.GetHicon"/> erzeugtes
|
||||
/// HICON frei. Ohne diesen Aufruf leckt jedes erzeugte Symbol ein
|
||||
/// GDI-Handle.
|
||||
///
|
||||
/// Bewusst DllImport statt LibraryImport: Letzteres verlangt
|
||||
/// AllowUnsafeBlocks fuer das gesamte Projekt, was fuer diesen einen
|
||||
/// Aufruf unverhaeltnismaessig waere.
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
private const uint RpcCAuthnLevelNone = 1;
|
||||
private const uint RpcCImpLevelIdentify = 2;
|
||||
private const uint EoacNone = 0;
|
||||
|
||||
/// <summary>Wurde zu spaet aufgerufen — COM war bereits gemarshallt.</summary>
|
||||
private const int RpcETooLate = unchecked((int)0x80010119);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeSecurity(
|
||||
IntPtr pSecDesc, int cAuthSvc, IntPtr asAuthSvc, IntPtr pReserved1,
|
||||
uint dwAuthnLevel, uint dwImpLevel, IntPtr pAuthList,
|
||||
uint dwCapabilities, IntPtr pReserved3);
|
||||
|
||||
/// <summary>
|
||||
/// Erlaubt CLMgr, Ereignisse in diesen Prozess zurueckzurufen.
|
||||
///
|
||||
/// CLMgr.exe ist ein eigener Prozess. Beim Anmelden der Ereignissenke
|
||||
/// (<c>IConnectionPoint::Advise</c>) ruft CLMgr in unseren Prozess zurueck.
|
||||
/// Ohne eigene Sicherheitsvorgabe verwendet COM Standardwerte, unter denen
|
||||
/// dieser Rueckruf mit E_ACCESSDENIED abgewiesen wird.
|
||||
///
|
||||
/// Authentifizierungsstufe NONE bedeutet: eingehende COM-Aufrufe an diesen
|
||||
/// Prozess werden nicht authentifiziert. Das ist die uebliche Einstellung
|
||||
/// fuer COM-Ereignissenken und betrifft nur diesen Prozess, der ausser der
|
||||
/// Senke keine Schnittstellen nach aussen anbietet. Wer es enger fassen
|
||||
/// will, probiert RPC_C_AUTHN_LEVEL_CONNECT (2) — das genuegt, solange
|
||||
/// CLMgr in derselben Sitzung und unter demselben Konto laeuft.
|
||||
///
|
||||
/// Muss vor dem ersten COM-Aufruf erfolgen; danach liefert Windows
|
||||
/// RPC_E_TOO_LATE.
|
||||
/// </summary>
|
||||
/// <returns>HRESULT des Aufrufs, 0 = Erfolg.</returns>
|
||||
internal static int InitializeComSecurity()
|
||||
{
|
||||
// Das Apartment steht durch [STAThread] fest; dieser Aufruf stellt nur
|
||||
// sicher, dass COM initialisiert ist, bevor die Sicherheit gesetzt wird.
|
||||
const uint coinitApartmentThreaded = 0x2;
|
||||
CoInitializeEx(IntPtr.Zero, coinitApartmentThreaded);
|
||||
|
||||
return CoInitializeSecurity(
|
||||
IntPtr.Zero, -1, IntPtr.Zero, IntPtr.Zero,
|
||||
RpcCAuthnLevelNone, RpcCImpLevelIdentify, IntPtr.Zero,
|
||||
EoacNone, IntPtr.Zero);
|
||||
}
|
||||
|
||||
internal static bool IsTooLate(int hresult) => hresult == RpcETooLate;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SwyxTray;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Sitzungsweit eindeutig (Local\), nicht maschinenweit: bei
|
||||
/// Terminalserver-Betrieb soll je Sitzung eine Instanz laufen duerfen,
|
||||
/// weil jede Sitzung ihren eigenen CLMgr hat.
|
||||
/// </summary>
|
||||
private const string InstanceMutexName = @"Local\SwyxTray.SingleInstance";
|
||||
|
||||
[STAThread]
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
Log.VerboseEnabled = args.Any(a =>
|
||||
a.Equals("--verbose", StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Equals("-v", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
using var mutex = new Mutex(initiallyOwned: true, InstanceMutexName, out var isFirstInstance);
|
||||
if (!isFirstInstance)
|
||||
{
|
||||
Log.Info("Eine Instanz laeuft bereits in dieser Sitzung — Start abgebrochen.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Muss vor dem ersten COM-Aufruf stehen, sonst kann CLMgr keine
|
||||
// Ereignisse in diesen Prozess zurueckrufen (E_ACCESSDENIED bei Advise).
|
||||
var securityResult = NativeMethods.InitializeComSecurity();
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// Die App hat kein Fenster. Ein unbehandelter Fehler wuerde sie sonst
|
||||
// kommentarlos beenden, deshalb wird alles protokolliert.
|
||||
Application.ThreadException += (_, e) =>
|
||||
Log.Error("Unbehandelter Fehler im UI-Thread.", e.Exception);
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
Log.Error("Unbehandelter Fehler.", e.ExceptionObject as Exception);
|
||||
|
||||
Log.Info($"SwyxTray gestartet (PID {Environment.ProcessId}, " +
|
||||
$"Sitzung {Process.GetCurrentProcess().SessionId}, " +
|
||||
$"{(Environment.Is64BitProcess ? "x64" : "x86")}).");
|
||||
|
||||
if (securityResult != 0)
|
||||
{
|
||||
Log.Info($"CoInitializeSecurity lieferte 0x{securityResult:X8}" +
|
||||
(NativeMethods.IsTooLate(securityResult)
|
||||
? " (RPC_E_TOO_LATE — COM war bereits initialisiert)."
|
||||
: "."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var context = new TrayApplicationContext();
|
||||
Application.Run(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TrayIcons.DisposeAll();
|
||||
Log.Info("SwyxTray beendet.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using IpPbx.CLMgrLib;
|
||||
|
||||
namespace SwyxTray.Swyx;
|
||||
|
||||
/// <summary>
|
||||
/// Einordnung der <see cref="CLMgrLineStates"/> aus der CLMgr-TypeLib.
|
||||
/// Die Werte stammen direkt aus dem Wrapper (Interop.CLMgr), sind also nicht
|
||||
/// geraten. Die vielen <c>LSWaitingFor*</c>-Werte sind Uebergangszustaende der
|
||||
/// CLMgr-Zustandsmaschine und werden hier bewusst als "belegt" behandelt.
|
||||
/// </summary>
|
||||
internal static class LineStateExtensions
|
||||
{
|
||||
/// <summary>Eingehender Ruf, der noch nicht angenommen wurde.</summary>
|
||||
public static bool IsRinging(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSRinging => true,
|
||||
CLMgrLineStates.LSKnocking => true, // zweiter Ruf waehrend eines Gespraechs
|
||||
CLMgrLineStates.LSActiveAlerting => true,
|
||||
CLMgrLineStates.LSActiveAlertingDC => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>Bestehende Sprechverbindung.</summary>
|
||||
public static bool IsActive(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSActive => true,
|
||||
CLMgrLineStates.LSConferenceActive => true,
|
||||
CLMgrLineStates.LSDirectCall => true,
|
||||
CLMgrLineStates.LSTransferring => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>Ausgehender Ruf im Aufbau.</summary>
|
||||
public static bool IsDialing(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSDialing => true,
|
||||
CLMgrLineStates.LSAlerting => true,
|
||||
CLMgrLineStates.LSHookOffInternal => true,
|
||||
CLMgrLineStates.LSHookOffExternal => true,
|
||||
CLMgrLineStates.LSBusy => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static bool IsOnHold(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSOnHold => true,
|
||||
CLMgrLineStates.LSConferenceOnHold => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>Leitung frei — wird in der Uebersicht ausgeblendet.</summary>
|
||||
public static bool IsIdle(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSInactive => true,
|
||||
CLMgrLineStates.LSTerminated => true,
|
||||
CLMgrLineStates.LSDisabled => true,
|
||||
CLMgrLineStates.LSNone => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>Deutscher Klartext fuer Kontextmenue und Sprechblasen.</summary>
|
||||
public static string ToDisplayText(this CLMgrLineStates state) => state switch
|
||||
{
|
||||
CLMgrLineStates.LSInactive => "frei",
|
||||
CLMgrLineStates.LSHookOffInternal => "abgehoben (intern)",
|
||||
CLMgrLineStates.LSHookOffExternal => "abgehoben (extern)",
|
||||
CLMgrLineStates.LSRinging => "klingelt",
|
||||
CLMgrLineStates.LSDialing => "waehlt",
|
||||
CLMgrLineStates.LSAlerting => "ruft an",
|
||||
CLMgrLineStates.LSKnocking => "klopft an",
|
||||
CLMgrLineStates.LSBusy => "besetzt",
|
||||
CLMgrLineStates.LSActive => "aktiv",
|
||||
CLMgrLineStates.LSOnHold => "gehalten",
|
||||
CLMgrLineStates.LSConferenceActive => "Konferenz aktiv",
|
||||
CLMgrLineStates.LSConferenceOnHold => "Konferenz gehalten",
|
||||
CLMgrLineStates.LSTerminated => "beendet",
|
||||
CLMgrLineStates.LSTransferring => "wird verbunden",
|
||||
CLMgrLineStates.LSDisabled => "deaktiviert",
|
||||
CLMgrLineStates.LSDirectCall => "Direktansprache",
|
||||
CLMgrLineStates.LSActiveAlerting => "zweiter Ruf",
|
||||
CLMgrLineStates.LSActiveAlertingDC => "zweiter Ruf (Direktansprache)",
|
||||
CLMgrLineStates.LSNone => "unbekannt",
|
||||
_ => state.ToString().StartsWith("LSWaitingFor", StringComparison.Ordinal)
|
||||
? "wird geschaltet"
|
||||
: state.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace SwyxTray.Swyx;
|
||||
|
||||
/// <summary>
|
||||
/// Ausschnitt aus <c>PubCLMgrMessages</c> (CLMgrPubTypes.h). Diese Aufzaehlung
|
||||
/// steckt nur im C-Header des SDK, nicht in der TypeLib, und ist deshalb hier
|
||||
/// nachgebildet — sie liefert die Bedeutung des <c>msg</c>-Parameters von
|
||||
/// <c>IClientLineMgrEventsPub.PubOnLineMgrNotification</c>.
|
||||
///
|
||||
/// Bewusst nur die Werte, die hier gebraucht werden. Der Zustand wird bei
|
||||
/// JEDER Benachrichtigung neu gelesen, nicht nur bei den bekannten Werten —
|
||||
/// ein falsch zugeordneter Wert kann die Anzeige also nicht verfaelschen,
|
||||
/// er beeinflusst nur den Protokolltext.
|
||||
/// </summary>
|
||||
internal enum PubCLMgrMessage
|
||||
{
|
||||
LineStateChanged = 0,
|
||||
LineSelectionChanged = 1,
|
||||
LineDetailsChanged = 2,
|
||||
UserDataChanged = 3,
|
||||
CallDetails = 4,
|
||||
ServerDown = 5,
|
||||
ServerUp = 6,
|
||||
WaveDeviceChanged = 7,
|
||||
GroupCallNotification = 8,
|
||||
NameKeyStateChanged = 9,
|
||||
NumberOfLinesChanged = 10,
|
||||
ClientShutDownRequest = 11
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using IpPbx.CLMgrLib;
|
||||
|
||||
namespace SwyxTray.Swyx;
|
||||
|
||||
/// <summary>
|
||||
/// Verbindung zum lokal laufenden SwyxIt!-Client ueber den Client Line Manager
|
||||
/// (CLMgr.exe) mittels der offiziellen Interop-Wrapper aus dem NuGet-Paket
|
||||
/// <c>Swyx.Client.ClmgrAPI</c>.
|
||||
///
|
||||
/// CLMgr.exe ist ein Out-of-Process-COM-Server. Beim Erzeugen von
|
||||
/// <see cref="ClientLineMgrClass"/> verbindet sich der Prozess mit der bereits
|
||||
/// laufenden CLMgr-Instanz der Sitzung — es wird also kein zweiter Client
|
||||
/// gestartet, sondern der vorhandene mitbenutzt.
|
||||
///
|
||||
/// Alle Zugriffe laufen ueber den UI-Thread der Anwendung. Das ist Absicht:
|
||||
/// CLMgr ist ein STA-COM-Server, und der WinForms-Thread ist STA und besitzt
|
||||
/// bereits eine Nachrichtenschleife. Damit werden auch die COM-Ereignisse
|
||||
/// automatisch dorthin gemarshallt und jedes manuelle Marshalling entfaellt.
|
||||
/// </summary>
|
||||
internal sealed class SwyxClient : IDisposable
|
||||
{
|
||||
/// <summary>Anmeldename gegenueber CLMgr; leer = angemeldeter Benutzer.</summary>
|
||||
private const string RegisterUserName = "";
|
||||
|
||||
private ClientLineMgrClass? _mgr;
|
||||
private IClientLineMgrDisp? _disp;
|
||||
private IClientLineMgrEventsPub_Event? _events;
|
||||
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler;
|
||||
|
||||
private int _userId = -1;
|
||||
private string _lastSignature = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
|
||||
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
|
||||
|
||||
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
|
||||
|
||||
public bool IsConnected => _disp is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Ob der Ereignis-Rueckkanal steht. Ist er es nicht, muss haeufiger
|
||||
/// abgefragt werden, damit die Anzeige nicht traege wirkt.
|
||||
/// </summary>
|
||||
public bool EventsActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stellt die Verbindung her, falls sie noch nicht besteht, und liest den
|
||||
/// Zustand ein. Wirft nicht — Fehler landen im Protokoll.
|
||||
/// Wird beim Start und danach zyklisch als Sicherheitsnetz aufgerufen.
|
||||
/// </summary>
|
||||
public void EnsureConnectedAndRefresh()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disp is null && !TryConnect())
|
||||
{
|
||||
Publish(SwyxSnapshot.Offline);
|
||||
return;
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private bool TryConnect()
|
||||
{
|
||||
ClientLineMgrClass? mgr = null;
|
||||
try
|
||||
{
|
||||
mgr = new ClientLineMgrClass();
|
||||
var disp = (IClientLineMgrDisp)mgr;
|
||||
|
||||
// DispInit meldet die Anwendung beim Line Manager an. Leerer
|
||||
// Servername = vorhandene Konfiguration des laufenden Clients
|
||||
// uebernehmen. Schlaegt das fehl, ist der Client meist noch im
|
||||
// Hochlauf — dann beim naechsten Zyklus erneut versuchen.
|
||||
disp.DispInit(string.Empty);
|
||||
|
||||
_userId = disp.DispRegisterUser(RegisterUserName);
|
||||
|
||||
_mgr = mgr;
|
||||
_disp = disp;
|
||||
mgr = null; // Besitz uebernommen, nicht mehr freigeben
|
||||
|
||||
HookEvents(_mgr);
|
||||
|
||||
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
|
||||
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Debug($"Verbindung zu CLMgr fehlgeschlagen (0x{ex.HResult:X8}): {ex.Message}");
|
||||
Cleanup();
|
||||
return false;
|
||||
}
|
||||
catch (InvalidCastException ex)
|
||||
{
|
||||
// CLMgr.exe ist ein Out-of-Process-Server: jedes QueryInterface
|
||||
// laedt den Proxy-Stub CLMgrPs64.dll (bzw. CLMgrPs.dll bei x86) in
|
||||
// diesen Prozess. Wird der von einer Anwendungssteuerungsrichtlinie
|
||||
// blockiert, schlaegt die Umwandlung mit 0x800711C7 fehl — das
|
||||
// sieht aus wie eine Versionsinkompatibilitaet, ist aber keine.
|
||||
var isPolicyBlock = ex.Message.Contains("0x800711C7", StringComparison.Ordinal);
|
||||
Log.Error(isPolicyBlock
|
||||
? "Der COM-Proxy CLMgrPs64.dll wurde von einer Anwendungssteuerungsrichtlinie " +
|
||||
"blockiert (0x800711C7). Smart App Control bzw. die WDAC-Richtlinie pruefen; " +
|
||||
"eine Aenderung an Smart App Control wirkt erst nach einem Neustart."
|
||||
: "CLMgr liefert nicht die erwarteten Schnittstellen. Passt die SwyxIt!-Version " +
|
||||
"zur Paketversion von Swyx.Client.ClmgrAPI?", ex);
|
||||
Cleanup();
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (mgr is not null)
|
||||
{
|
||||
Marshal.FinalReleaseComObject(mgr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet die Ereignissenke an. Schlaegt das fehl, bleibt die Verbindung
|
||||
/// bestehen — die Anzeige wird dann allein ueber den Abfragezyklus
|
||||
/// aktualisiert. Ein fehlender Rueckkanal ist ein Komfortverlust, kein
|
||||
/// Grund, die Verbindung zu verwerfen.
|
||||
/// </summary>
|
||||
private void HookEvents(ClientLineMgrClass mgr)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = (IClientLineMgrEventsPub_Event)mgr;
|
||||
var handler = new IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler(
|
||||
OnLineMgrNotification);
|
||||
events.PubOnLineMgrNotification += handler;
|
||||
|
||||
_events = events;
|
||||
_handler = handler;
|
||||
EventsActive = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException or InvalidCastException)
|
||||
{
|
||||
EventsActive = false;
|
||||
_events = null;
|
||||
_handler = null;
|
||||
|
||||
var hint = ex is UnauthorizedAccessException
|
||||
? " CLMgr darf nicht in diesen Prozess zurueckrufen — pruefen, ob " +
|
||||
"CoInitializeSecurity vor dem ersten COM-Aufruf gelaufen ist und ob " +
|
||||
"CLMgr in derselben Sitzung und unter demselben Konto laeuft."
|
||||
: string.Empty;
|
||||
|
||||
Log.Error("Ereignissenke konnte nicht angemeldet werden; es wird nur noch " +
|
||||
"zyklisch abgefragt." + hint, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Benachrichtigung des Line Managers. Bei JEDER Meldung wird der
|
||||
/// Zustand neu gelesen — das ist billig (wenige COM-Zugriffe) und macht die
|
||||
/// Anzeige unabhaengig davon, ob eine Meldungsnummer korrekt zugeordnet ist.
|
||||
/// </summary>
|
||||
private void OnLineMgrNotification(int msg, int param)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var known = Enum.IsDefined(typeof(PubCLMgrMessage), msg)
|
||||
? ((PubCLMgrMessage)msg).ToString()
|
||||
: $"Msg{msg}";
|
||||
Log.Debug($"Benachrichtigung {known} (msg={msg}, param={param})");
|
||||
|
||||
if (msg == (int)PubCLMgrMessage.ClientShutDownRequest)
|
||||
{
|
||||
Log.Info("CLMgr faehrt herunter — Verbindung wird getrennt.");
|
||||
Cleanup();
|
||||
Publish(SwyxSnapshot.Offline);
|
||||
return;
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var disp = _disp;
|
||||
if (disp is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Publish(ReadSnapshot(disp));
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Log.Info($"Verbindung zu CLMgr verloren (0x{ex.HResult:X8}): {ex.Message}");
|
||||
Cleanup();
|
||||
Publish(SwyxSnapshot.Offline);
|
||||
}
|
||||
}
|
||||
|
||||
private static SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
|
||||
{
|
||||
var lineCount = disp.DispNumberOfLines;
|
||||
var selected = disp.DispSelectedLineNumber;
|
||||
var lines = new List<SwyxLineInfo>(Math.Max(0, lineCount));
|
||||
|
||||
for (var i = 0; i < lineCount; i++)
|
||||
{
|
||||
// DispGetLine liefert null, wenn die Leitung nicht verfuegbar ist.
|
||||
if (disp.DispGetLine(i) is not IClientLineDisp line)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lines.Add(new SwyxLineInfo(
|
||||
Index: i,
|
||||
State: (CLMgrLineStates)line.DispState,
|
||||
PeerNumber: line.DispPeerNumber ?? string.Empty,
|
||||
PeerName: line.DispPeerName ?? string.Empty,
|
||||
IsSelected: i == selected));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(line);
|
||||
}
|
||||
}
|
||||
|
||||
return new SwyxSnapshot(
|
||||
IsConnected: true,
|
||||
IsServerUp: disp.DispIsServerUp != 0,
|
||||
UserName: disp.DispGetCurrentUser ?? string.Empty,
|
||||
ServerName: disp.DispGetCurrentServer ?? string.Empty,
|
||||
Lines: lines);
|
||||
}
|
||||
|
||||
private void Publish(SwyxSnapshot snapshot)
|
||||
{
|
||||
var signature = BuildSignature(snapshot);
|
||||
if (signature == _lastSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSignature = signature;
|
||||
var previous = Current;
|
||||
Current = snapshot;
|
||||
|
||||
Log.Debug(snapshot.IsConnected
|
||||
? $"Zustand: {snapshot.Overall} | " + string.Join(" | ", snapshot.Lines.Select(l =>
|
||||
$"L{l.Index + 1}={l.State}({(int)l.State})" +
|
||||
(l.IsBusy ? $" {l.PeerDisplayLong}" : string.Empty) +
|
||||
(l.IsSelected ? " *" : string.Empty)))
|
||||
: "Zustand: getrennt");
|
||||
|
||||
SnapshotChanged?.Invoke(previous, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleichsschluessel. Records mit Listen vergleichen die Liste per
|
||||
/// Referenz, deshalb eine eigene, stabile Signatur.
|
||||
/// </summary>
|
||||
private static string BuildSignature(SwyxSnapshot s)
|
||||
{
|
||||
var sb = new StringBuilder()
|
||||
.Append(s.IsConnected).Append('|')
|
||||
.Append(s.IsServerUp).Append('|')
|
||||
.Append(s.UserName).Append('|')
|
||||
.Append(s.ServerName);
|
||||
|
||||
foreach (var l in s.Lines)
|
||||
{
|
||||
sb.Append("||").Append(l.Index).Append(':')
|
||||
.Append((int)l.State).Append(':')
|
||||
.Append(l.PeerNumber).Append(':')
|
||||
.Append(l.PeerName).Append(':')
|
||||
.Append(l.IsSelected ? '1' : '0');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Steuerung. Diese Methoden werden vom WebSocket-Zugang aufgerufen, laufen
|
||||
// aber wie alles andere im UI-Thread (siehe UiDispatcher). Sie werfen bei
|
||||
// Fehlern; der Aufrufer macht daraus eine Fehlerantwort an den Client.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Waehlt eine Rufnummer auf einer freien Leitung. Liefert die belegte
|
||||
/// Leitung (1-basiert), sofern sie sich sofort erkennen laesst.
|
||||
/// </summary>
|
||||
public int? Dial(string number)
|
||||
{
|
||||
var disp = RequireConnection();
|
||||
var dialstring = SanitizeNumber(number);
|
||||
|
||||
Log.Info($"Waehlt '{dialstring}'.");
|
||||
|
||||
// Die Ex-Varianten haben einen undokumentierten Rueckgabewert; die
|
||||
// einfache Form meldet einen Fehler sauber als COMException. Der Aufruf
|
||||
// ist laut SDK ohnehin asynchron — welche Leitung tatsaechlich belegt
|
||||
// wird und ob der Ruf durchkommt, zeigt erst der Folgezustand.
|
||||
disp.DispSimpleDial(dialstring);
|
||||
Refresh();
|
||||
|
||||
var line = Current.Lines.FirstOrDefault(l => l.State.IsDialing());
|
||||
return line?.Index + 1;
|
||||
}
|
||||
|
||||
/// <summary>Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert).</summary>
|
||||
public int Answer(int? line)
|
||||
{
|
||||
var index = ResolveLine(line, l => l.State.IsRinging(), "Es klingelt keine Leitung.");
|
||||
WithLine(index, l => l.DispHookOff());
|
||||
Refresh();
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/// <summary>Legt auf; liefert die Leitung (1-basiert).</summary>
|
||||
public int Hangup(int? line)
|
||||
{
|
||||
var index = ResolveLine(line, l => l.IsBusy, "Es ist keine Leitung belegt.");
|
||||
WithLine(index, l => l.DispHookOn());
|
||||
Refresh();
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/// <summary>Stellt ein Gespraech in die Warteschleife.</summary>
|
||||
public int Hold(int? line)
|
||||
{
|
||||
var index = ResolveLine(line, l => l.State.IsActive(), "Es ist kein Gespraech aktiv.");
|
||||
WithLine(index, l => l.DispHold());
|
||||
Refresh();
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
private IClientLineMgrDisp RequireConnection()
|
||||
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt die zu bedienende Leitung. Ohne Angabe wird die passende
|
||||
/// gesucht — bevorzugt die ausgewaehlte, damit die Bedienung ueber den
|
||||
/// Browser und ueber den Client zum selben Ergebnis fuehren.
|
||||
/// </summary>
|
||||
private int ResolveLine(int? oneBased, Func<SwyxLineInfo, bool> matches, string noneFound)
|
||||
{
|
||||
if (oneBased is { } number)
|
||||
{
|
||||
var wanted = Current.Lines.FirstOrDefault(l => l.Index == number - 1)
|
||||
?? throw new ArgumentException($"Leitung {number} gibt es nicht.");
|
||||
return wanted.Index;
|
||||
}
|
||||
|
||||
var candidates = Current.Lines.Where(matches).ToList();
|
||||
var line = candidates.FirstOrDefault(l => l.IsSelected) ?? candidates.FirstOrDefault();
|
||||
return line?.Index ?? throw new InvalidOperationException(noneFound);
|
||||
}
|
||||
|
||||
private void WithLine(int index, Action<IClientLineDisp> action)
|
||||
{
|
||||
var disp = RequireConnection();
|
||||
|
||||
if (disp.DispGetLine(index) is not IClientLineDisp line)
|
||||
{
|
||||
throw new InvalidOperationException($"Leitung {index + 1} ist nicht verfuegbar.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
action(line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aus dem Web kommen Nummern mit Leerzeichen, Bindestrichen und Klammern.
|
||||
/// Die werden entfernt; alles darueber hinaus wird abgewiesen, statt es an
|
||||
/// CLMgr weiterzureichen — der Wahlstring darf keine Ueberraschungen aus
|
||||
/// einer fremden Webseite enthalten.
|
||||
/// </summary>
|
||||
public static string SanitizeNumber(string? number)
|
||||
{
|
||||
var cleaned = new string((number ?? string.Empty)
|
||||
.Where(c => !char.IsWhiteSpace(c) && c is not ('-' or '(' or ')' or '.' or '/'))
|
||||
.ToArray());
|
||||
|
||||
var isValid = cleaned.Length is > 0 and <= 64
|
||||
&& cleaned.All(c => char.IsAsciiDigit(c) || c is '+' or '*' or '#')
|
||||
&& cleaned.IndexOf('+') <= 0;
|
||||
|
||||
return isValid
|
||||
? cleaned
|
||||
: throw new ArgumentException($"'{number}' ist keine gueltige Rufnummer.");
|
||||
}
|
||||
|
||||
/// <summary>Verbindung verwerfen und beim naechsten Zyklus neu aufbauen.</summary>
|
||||
public void Reconnect()
|
||||
{
|
||||
Cleanup();
|
||||
_lastSignature = string.Empty;
|
||||
EnsureConnectedAndRefresh();
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
if (_events is not null && _handler is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_events.PubOnLineMgrNotification -= _handler;
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
// Server bereits weg — Abmelden ist dann gegenstandslos.
|
||||
}
|
||||
}
|
||||
|
||||
if (_disp is not null && _userId >= 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_disp.DispReleaseUser(_userId);
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
// dito
|
||||
}
|
||||
}
|
||||
|
||||
if (_mgr is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Marshal.FinalReleaseComObject(_mgr);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidComObjectException or ArgumentException)
|
||||
{
|
||||
// Bereits freigegeben ist beim Abbau kein Fehler.
|
||||
}
|
||||
}
|
||||
|
||||
_handler = null;
|
||||
_events = null;
|
||||
_disp = null;
|
||||
_mgr = null;
|
||||
_userId = -1;
|
||||
EventsActive = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using IpPbx.CLMgrLib;
|
||||
|
||||
namespace SwyxTray.Swyx;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregierter Zustand ueber alle Leitungen — das, was das Tray-Icon anzeigt.
|
||||
/// </summary>
|
||||
public enum SwyxOverallState
|
||||
{
|
||||
/// <summary>CLMgr nicht erreichbar (SwyxIt! laeuft nicht).</summary>
|
||||
Offline,
|
||||
|
||||
/// <summary>Verbunden, aber der SwyxServer meldet sich nicht.</summary>
|
||||
ServerDown,
|
||||
|
||||
/// <summary>Verbunden, keine belegte Leitung.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Mindestens eine Leitung klingelt (eingehend).</summary>
|
||||
Ringing,
|
||||
|
||||
/// <summary>Ausgehender Ruf im Aufbau.</summary>
|
||||
Dialing,
|
||||
|
||||
/// <summary>Mindestens ein Gespraech aktiv.</summary>
|
||||
InCall,
|
||||
|
||||
/// <summary>Gespraeche nur noch gehalten.</summary>
|
||||
OnHold
|
||||
}
|
||||
|
||||
/// <summary>Momentaufnahme einer Leitung.</summary>
|
||||
public sealed record SwyxLineInfo(
|
||||
int Index,
|
||||
CLMgrLineStates State,
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
bool IsSelected)
|
||||
{
|
||||
/// <summary>Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.</summary>
|
||||
public string PeerDisplay =>
|
||||
!string.IsNullOrWhiteSpace(PeerName) ? PeerName
|
||||
: !string.IsNullOrWhiteSpace(PeerNumber) ? PeerNumber
|
||||
: "unbekannt";
|
||||
|
||||
/// <summary>Name und Nummer, sofern beides bekannt und verschieden.</summary>
|
||||
public string PeerDisplayLong =>
|
||||
!string.IsNullOrWhiteSpace(PeerName) && !string.IsNullOrWhiteSpace(PeerNumber)
|
||||
? $"{PeerName} ({PeerNumber})"
|
||||
: PeerDisplay;
|
||||
|
||||
public bool IsBusy => !State.IsIdle();
|
||||
}
|
||||
|
||||
/// <summary>Momentaufnahme des gesamten Clients.</summary>
|
||||
public sealed record SwyxSnapshot(
|
||||
bool IsConnected,
|
||||
bool IsServerUp,
|
||||
string UserName,
|
||||
string ServerName,
|
||||
IReadOnlyList<SwyxLineInfo> Lines)
|
||||
{
|
||||
public static SwyxSnapshot Offline { get; } =
|
||||
new(false, false, string.Empty, string.Empty, Array.Empty<SwyxLineInfo>());
|
||||
|
||||
/// <summary>Leitungen mit Gespraech bzw. Gespraechsaufbau.</summary>
|
||||
public IEnumerable<SwyxLineInfo> BusyLines => Lines.Where(l => l.IsBusy);
|
||||
|
||||
public SwyxOverallState Overall
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
return SwyxOverallState.Offline;
|
||||
}
|
||||
|
||||
if (!IsServerUp)
|
||||
{
|
||||
return SwyxOverallState.ServerDown;
|
||||
}
|
||||
|
||||
if (Lines.Any(l => l.State.IsRinging()))
|
||||
{
|
||||
return SwyxOverallState.Ringing;
|
||||
}
|
||||
|
||||
if (Lines.Any(l => l.State.IsActive()))
|
||||
{
|
||||
return SwyxOverallState.InCall;
|
||||
}
|
||||
|
||||
if (Lines.Any(l => l.State.IsDialing()))
|
||||
{
|
||||
return SwyxOverallState.Dialing;
|
||||
}
|
||||
|
||||
if (Lines.Any(l => l.State.IsOnHold()))
|
||||
{
|
||||
return SwyxOverallState.OnHold;
|
||||
}
|
||||
|
||||
return SwyxOverallState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kurztext fuer QuickInfo und Kontextmenue-Kopf.</summary>
|
||||
public string StatusText => Overall switch
|
||||
{
|
||||
SwyxOverallState.Offline => "SwyxIt! nicht verbunden",
|
||||
SwyxOverallState.ServerDown => "SwyxServer nicht erreichbar",
|
||||
SwyxOverallState.Ringing => "Eingehender Ruf",
|
||||
SwyxOverallState.Dialing => "Verbindungsaufbau",
|
||||
SwyxOverallState.InCall => "Im Gespraech",
|
||||
SwyxOverallState.OnHold => "Gespraech gehalten",
|
||||
_ => "Bereit"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- WinExe = kein Konsolenfenster. Die App hat kein Hauptfenster,
|
||||
nur ein NotifyIcon im Infobereich der Taskleiste. -->
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>SwyxTray</RootNamespace>
|
||||
<AssemblyName>SwyxTray</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
||||
<!-- Das Paket Swyx.Client.ClmgrAPI waehlt die Interop-Assembly ueber
|
||||
$(Platform) aus und bricht bei 'AnyCPU' mit einem Fehler ab.
|
||||
x64 ist moeglich, weil CLMgr.exe ein Out-of-Process-COM-Server ist
|
||||
(LocalServer32) und der 64-Bit-Proxy CLMgrPs64.dll mitinstalliert wird.
|
||||
Fuer 32 Bit einfach auf x86 umstellen. -->
|
||||
<Platforms>x64;x86</Platforms>
|
||||
<Platform>x64</Platform>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>false</SelfContained>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Offizieller Wrapper von Enreach: Interop-Assemblies der
|
||||
ClientLineManager-COM-API. Version passend zur SwyxIt!-Version. -->
|
||||
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,506 @@
|
||||
using System.Diagnostics;
|
||||
using SwyxTray.Swyx;
|
||||
using SwyxTray.Web;
|
||||
|
||||
namespace SwyxTray;
|
||||
|
||||
/// <summary>
|
||||
/// Der eigentliche Anwendungsrumpf: kein Fenster, nur ein NotifyIcon im
|
||||
/// Infobereich der Taskleiste samt Kontextmenue.
|
||||
/// </summary>
|
||||
internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Sicherheitsnetz neben den COM-Ereignissen: faengt einen zwischenzeitlich
|
||||
/// beendeten CLMgr ab und stellt die Verbindung wieder her. Die eigentliche
|
||||
/// Aktualisierung laeuft ereignisgesteuert, nicht ueber diesen Zyklus.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SafetyInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Kuerzeres Intervall, wenn der Ereignis-Rueckkanal nicht steht — dann ist
|
||||
/// die Abfrage die einzige Quelle fuer Zustandsaenderungen.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly NotifyIcon _notifyIcon;
|
||||
private readonly SwyxClient _client;
|
||||
private readonly System.Windows.Forms.Timer _safetyTimer;
|
||||
private readonly ToolStripMenuItem _statusItem;
|
||||
private readonly ToolStripMenuItem _linesItem;
|
||||
private readonly ToolStripSeparator _linesSeparator;
|
||||
private readonly ToolStripMenuItem _webSocketItem;
|
||||
private readonly ToolStripMenuItem _copyEndpointItem;
|
||||
private readonly ToolStripMenuItem _readTabsItem;
|
||||
private readonly ToolStripMenuItem _openTabItem;
|
||||
private readonly ToolStripMenuItem _closeTabItem;
|
||||
|
||||
/// <summary>Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen".</summary>
|
||||
private const string AssecutorUrl = "https://www.assecutor.de";
|
||||
private const string AssecutorTitle = "Assecutor Data Service GmbH";
|
||||
|
||||
private LocalWebSocketServer? _server;
|
||||
private bool _disposed;
|
||||
|
||||
public TrayApplicationContext()
|
||||
{
|
||||
_statusItem = new ToolStripMenuItem("Wird verbunden …") { Enabled = false };
|
||||
_linesItem = new ToolStripMenuItem("Leitungen") { Visible = false };
|
||||
_linesSeparator = new ToolStripSeparator { Visible = false };
|
||||
|
||||
_copyEndpointItem = new ToolStripMenuItem(
|
||||
"Verbindungsdaten kopieren", null, (_, _) => CopyEndpoint()) { Enabled = false };
|
||||
_webSocketItem = new ToolStripMenuItem("WebSocket-Zugang");
|
||||
_webSocketItem.DropDownItems.AddRange(new ToolStripItem[]
|
||||
{
|
||||
_copyEndpointItem,
|
||||
new ToolStripMenuItem("Konfiguration oeffnen", null,
|
||||
(_, _) => OpenWithShell(WebSocketConfig.FilePath))
|
||||
});
|
||||
|
||||
_readTabsItem = new ToolStripMenuItem(
|
||||
"Lese Tabs", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, ReadTabsAsync)) { Enabled = false };
|
||||
_openTabItem = new ToolStripMenuItem(
|
||||
"Tab oeffnen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, OpenAssecutorTabAsync)) { Enabled = false };
|
||||
_closeTabItem = new ToolStripMenuItem(
|
||||
"Tab schliessen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, CloseAssecutorTabAsync)) { Enabled = false };
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.AddRange(new ToolStripItem[]
|
||||
{
|
||||
_statusItem,
|
||||
_linesItem,
|
||||
_linesSeparator,
|
||||
_webSocketItem,
|
||||
_readTabsItem,
|
||||
_openTabItem,
|
||||
_closeTabItem,
|
||||
new ToolStripSeparator(),
|
||||
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
|
||||
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
|
||||
new ToolStripSeparator(),
|
||||
new ToolStripMenuItem("Beenden", null, (_, _) => ExitApplication())
|
||||
});
|
||||
|
||||
_notifyIcon = new NotifyIcon
|
||||
{
|
||||
Icon = TrayIcons.For(SwyxOverallState.Offline),
|
||||
Text = "SwyxTray — wird verbunden",
|
||||
ContextMenuStrip = menu,
|
||||
Visible = true
|
||||
};
|
||||
_notifyIcon.DoubleClick += (_, _) => ShowStatusBalloon();
|
||||
|
||||
_client = new SwyxClient();
|
||||
_client.SnapshotChanged += OnSnapshotChanged;
|
||||
|
||||
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
|
||||
_safetyTimer.Tick += (_, _) =>
|
||||
{
|
||||
_client.EnsureConnectedAndRefresh();
|
||||
UpdateWebSocketMenu();
|
||||
|
||||
var wanted = (int)(_client.IsConnected && !_client.EventsActive
|
||||
? PollingOnlyInterval
|
||||
: SafetyInterval).TotalMilliseconds;
|
||||
if (_safetyTimer!.Interval != wanted)
|
||||
{
|
||||
_safetyTimer.Interval = wanted;
|
||||
}
|
||||
};
|
||||
_safetyTimer.Start();
|
||||
|
||||
// Erster Verbindungsversuch, sobald die Nachrichtenschleife laeuft.
|
||||
// Direkt im Konstruktor waere er zu frueh — COM-Ereignisse brauchen
|
||||
// die Schleife, und ein Fehler hier wuerde den Start abbrechen.
|
||||
// Der WebSocket-Zugang startet aus demselben Grund erst hier: sein
|
||||
// UiDispatcher braucht den SynchronizationContext der Schleife.
|
||||
BeginInvokeOnMessageLoop(() =>
|
||||
{
|
||||
_client.EnsureConnectedAndRefresh();
|
||||
StartWebSocketServer();
|
||||
});
|
||||
}
|
||||
|
||||
private void StartWebSocketServer()
|
||||
{
|
||||
if (WebSocketConfig.Load() is not { } config)
|
||||
{
|
||||
UpdateWebSocketMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (UiDispatcher.Capture() is not { } dispatcher)
|
||||
{
|
||||
Log.Error("Kein UI-Kontext vorhanden — der WebSocket-Zugang bleibt aus.");
|
||||
UpdateWebSocketMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
var server = new LocalWebSocketServer(config, _client, dispatcher);
|
||||
server.Start();
|
||||
|
||||
if (server.IsRunning)
|
||||
{
|
||||
_server = server;
|
||||
_server.Broadcast(_client.Current);
|
||||
}
|
||||
else
|
||||
{
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
UpdateWebSocketMenu();
|
||||
}
|
||||
|
||||
private void UpdateWebSocketMenu()
|
||||
{
|
||||
// Der Hinweis auf die abgeschaltete Pruefung gehoert sichtbar ins
|
||||
// Menue: sonst waere einer Instanz nicht anzusehen, dass sie jeden
|
||||
// hereinlaesst.
|
||||
var debugHinweis = WebSocketConfig.SecurityDisabled ? " — OHNE PRUEFUNG" : string.Empty;
|
||||
var netzHinweis = _server is { IsRemote: true } ? " — im Netz" : string.Empty;
|
||||
|
||||
_webSocketItem.Text = _server is { IsRunning: true } server
|
||||
? $"WebSocket: Port {server.Port}" +
|
||||
(server.PluginPort > 0 ? $" +{server.PluginPort}" : string.Empty) +
|
||||
$", {server.ConnectionCount} Verbindung(en){netzHinweis}{debugHinweis}"
|
||||
: "WebSocket: aus (siehe Protokoll)";
|
||||
|
||||
_copyEndpointItem.Enabled = _server is { IsRunning: true };
|
||||
_readTabsItem.Enabled = _openTabItem.Enabled = _closeTabItem.Enabled =
|
||||
_server is { IsRunning: true, PluginPort: > 0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsamer Rahmen der Tab-Menuepunkte: waehrend der Auftrag beim
|
||||
/// Firefox-Plugin laeuft, ist der Punkt gesperrt — sonst stiesse ein
|
||||
/// zweiter Klick eine zweite Anfrage an. async void ist hier richtig:
|
||||
/// ein Menue-Handler hat keinen Aufrufer, der auf ein Task warten koennte.
|
||||
/// </summary>
|
||||
private async void RunTabAction(ToolStripMenuItem item, Func<LocalWebSocketServer, Task> action)
|
||||
{
|
||||
if (_server is not { IsRunning: true } server)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.Enabled = false;
|
||||
try
|
||||
{
|
||||
await action(server);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
item.Enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt die offenen Tabs vom Firefox-Plugin (ueber den Plugin-Port) und
|
||||
/// zeigt ihre Titel als Sprechblase; die vollstaendige Liste samt Adressen
|
||||
/// steht im Protokoll.
|
||||
/// </summary>
|
||||
private async Task ReadTabsAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs.Count == 0)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs", "Keine Tabs offen.", ToolTipIcon.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox meldet {tabs.Count} offene(n) Tab(s): " + string.Join(" | ",
|
||||
tabs.Select(t => $"{(t.Active == true ? "*" : "")}{t.Title} <{t.Url}>")));
|
||||
|
||||
// Eine Sprechblase fasst hoechstens 255 Zeichen — die ersten Titel
|
||||
// genuegen, der Rest steht im Protokoll.
|
||||
const int maxShown = 5;
|
||||
var lines = tabs.Take(maxShown)
|
||||
.Select(t => Truncate((t.Active == true ? "▶ " : "• ") + (t.Title ?? "(ohne Titel)"), 40))
|
||||
.ToList();
|
||||
if (tabs.Count > maxShown)
|
||||
{
|
||||
lines.Add($"… und {tabs.Count - maxShown} weitere (siehe Protokoll)");
|
||||
}
|
||||
|
||||
_notifyIcon.ShowBalloonTip(10_000, $"{tabs.Count} offene(r) Firefox-Tab(s)",
|
||||
Truncate(string.Join(Environment.NewLine, lines), 255), ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
/// <summary>Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite.</summary>
|
||||
private async Task OpenAssecutorTabAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabId = await server.TryOpenTabAsync(AssecutorUrl, CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabId is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox-Tab {tabId} mit {AssecutorUrl} geoeffnet.");
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen", $"{AssecutorUrl} geoeffnet.", ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando
|
||||
/// als Komposition aus den Grundauftraegen des Kanals: erst <c>list</c>,
|
||||
/// dann je Treffer ein <c>close</c> mit der Tab-Id. So laesst sich auch
|
||||
/// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt.
|
||||
/// </summary>
|
||||
private async Task CloseAssecutorTabAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var matching = tabs
|
||||
.Where(t => t.Id is not null
|
||||
&& t.Title?.Contains(AssecutorTitle, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
$"Kein Tab \"{AssecutorTitle}\" offen.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var closed = 0;
|
||||
foreach (var tab in matching)
|
||||
{
|
||||
if (await server.TryCloseTabAsync(tab.Id!.Value, CancellationToken.None))
|
||||
{
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox-Tab(s) \"{AssecutorTitle}\": {closed} von {matching.Count} geschlossen.");
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
closed == matching.Count
|
||||
? $"Tab \"{AssecutorTitle}\" geschlossen."
|
||||
: $"Nur {closed} von {matching.Count} Tabs geschlossen (siehe Protokoll).",
|
||||
closed == matching.Count ? ToolTipIcon.Info : ToolTipIcon.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legt Adresse samt Token in die Zwischenablage — beim Einrichten einer
|
||||
/// Seite ist das der einzige Wert, den jemand von Hand uebertragen muss.
|
||||
/// </summary>
|
||||
private void CopyEndpoint()
|
||||
{
|
||||
if (_server is not { IsRunning: true } server)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(server.Endpoint);
|
||||
_notifyIcon.ShowBalloonTip(5_000, "WebSocket-Zugang",
|
||||
"Adresse und Token liegen in der Zwischenablage.", ToolTipIcon.Info);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.ExternalException ex)
|
||||
{
|
||||
Log.Error("Zwischenablage nicht verfuegbar.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fuehrt eine Aktion aus, sobald die Nachrichtenschleife laeuft.
|
||||
/// Ein Timer mit kurzem Intervall ist hier das einfachste Mittel, weil
|
||||
/// ohne Fenster kein Control fuer BeginInvoke zur Verfuegung steht.
|
||||
/// </summary>
|
||||
private static void BeginInvokeOnMessageLoop(Action action)
|
||||
{
|
||||
var starter = new System.Windows.Forms.Timer { Interval = 1 };
|
||||
starter.Tick += (s, _) =>
|
||||
{
|
||||
var timer = (System.Windows.Forms.Timer)s!;
|
||||
timer.Stop();
|
||||
timer.Dispose();
|
||||
action();
|
||||
};
|
||||
starter.Start();
|
||||
}
|
||||
|
||||
private void OnSnapshotChanged(SwyxSnapshot previous, SwyxSnapshot current)
|
||||
{
|
||||
UpdateIcon(current);
|
||||
UpdateMenu(current);
|
||||
NotifyAboutNewCalls(previous, current);
|
||||
_server?.Broadcast(previous, current);
|
||||
}
|
||||
|
||||
private void UpdateIcon(SwyxSnapshot s)
|
||||
{
|
||||
_notifyIcon.Icon = TrayIcons.For(s.Overall);
|
||||
|
||||
var busy = s.BusyLines.ToList();
|
||||
var detail = busy.Count switch
|
||||
{
|
||||
0 => s.IsConnected ? $"{s.UserName} @ {s.ServerName}" : "SwyxIt! nicht verbunden",
|
||||
1 => $"{busy[0].State.ToDisplayText()}: {busy[0].PeerDisplay}",
|
||||
_ => $"{busy.Count} belegte Leitungen"
|
||||
};
|
||||
|
||||
// Die QuickInfo eines NotifyIcon ist auf 63 Zeichen begrenzt; laengerer
|
||||
// Text wird von Windows kommentarlos verworfen.
|
||||
_notifyIcon.Text = Truncate($"Swyx — {s.StatusText}\n{detail}", 63);
|
||||
}
|
||||
|
||||
private void UpdateMenu(SwyxSnapshot s)
|
||||
{
|
||||
_statusItem.Text = s.IsConnected
|
||||
? $"{s.StatusText} — {s.UserName}"
|
||||
: s.StatusText;
|
||||
|
||||
var busy = s.BusyLines.ToList();
|
||||
_linesItem.DropDownItems.Clear();
|
||||
|
||||
foreach (var line in busy)
|
||||
{
|
||||
var text = $"Leitung {line.Index + 1}: {line.State.ToDisplayText()} — {line.PeerDisplayLong}";
|
||||
_linesItem.DropDownItems.Add(new ToolStripMenuItem(text) { Enabled = false });
|
||||
}
|
||||
|
||||
_linesItem.Text = busy.Count == 1 ? "1 belegte Leitung" : $"{busy.Count} belegte Leitungen";
|
||||
_linesItem.Visible = busy.Count > 0;
|
||||
_linesSeparator.Visible = busy.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprechblase nur fuer Leitungen, die neu zu klingeln beginnen — nicht bei
|
||||
/// jedem Zustandswechsel, sonst wird der Benutzer waehrend eines Gespraechs
|
||||
/// mehrfach benachrichtigt.
|
||||
/// </summary>
|
||||
private void NotifyAboutNewCalls(SwyxSnapshot previous, SwyxSnapshot current)
|
||||
{
|
||||
var previouslyRinging = previous.Lines
|
||||
.Where(l => l.State.IsRinging())
|
||||
.Select(l => l.Index)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var line in current.Lines.Where(l => l.State.IsRinging()))
|
||||
{
|
||||
if (previouslyRinging.Contains(line.Index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.Info($"Eingehender Ruf auf Leitung {line.Index + 1}: {line.PeerDisplayLong}");
|
||||
_notifyIcon.ShowBalloonTip(
|
||||
10_000,
|
||||
"Eingehender Ruf",
|
||||
$"{line.PeerDisplayLong}\nLeitung {line.Index + 1}",
|
||||
ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowStatusBalloon()
|
||||
{
|
||||
var s = _client.Current;
|
||||
var busy = s.BusyLines.ToList();
|
||||
var body = busy.Count == 0
|
||||
? (s.IsConnected ? $"Angemeldet als {s.UserName} an {s.ServerName}." : "Keine Verbindung zu SwyxIt!.")
|
||||
: string.Join(Environment.NewLine,
|
||||
busy.Select(l => $"Leitung {l.Index + 1}: {l.State.ToDisplayText()} — {l.PeerDisplayLong}"));
|
||||
|
||||
_notifyIcon.ShowBalloonTip(5_000, s.StatusText, body, ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
private void Reconnect()
|
||||
{
|
||||
Log.Info("Neuverbindung durch Benutzer angefordert.");
|
||||
_client.Reconnect();
|
||||
}
|
||||
|
||||
private void OpenLog()
|
||||
{
|
||||
if (!File.Exists(Log.FilePath))
|
||||
{
|
||||
Log.Info("Protokoll auf Wunsch geoeffnet.");
|
||||
}
|
||||
|
||||
OpenWithShell(Log.FilePath);
|
||||
}
|
||||
|
||||
private static void OpenWithShell(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||
{
|
||||
Log.Error($"{path} konnte nicht geoeffnet werden.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
// Symbol sofort ausblenden, sonst bleibt bis zum naechsten Ueberfahren
|
||||
// mit der Maus eine Leiche im Infobereich stehen.
|
||||
_notifyIcon.Visible = false;
|
||||
ExitThread();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_safetyTimer.Stop();
|
||||
_safetyTimer.Dispose();
|
||||
_server?.Dispose();
|
||||
_client.SnapshotChanged -= OnSnapshotChanged;
|
||||
_client.Dispose();
|
||||
_notifyIcon.Visible = false;
|
||||
_notifyIcon.ContextMenuStrip?.Dispose();
|
||||
_notifyIcon.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using SwyxTray.Swyx;
|
||||
|
||||
namespace SwyxTray;
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt die Symbole zur Laufzeit (Kreis mit Hoererbogen), damit keine
|
||||
/// .ico-Dateien mitgeliefert werden muessen. Wer eigene Symbole verwenden
|
||||
/// will, ersetzt einfach <see cref="For"/> durch das Laden aus Ressourcen.
|
||||
///
|
||||
/// Die Symbole werden einmal je Zustand erzeugt und zwischengespeichert —
|
||||
/// GDI-Handles sind eine begrenzte Ressource.
|
||||
/// </summary>
|
||||
internal static class TrayIcons
|
||||
{
|
||||
private static readonly Dictionary<SwyxOverallState, Icon> Cache = new();
|
||||
|
||||
private static readonly Dictionary<SwyxOverallState, Color> Colors = new()
|
||||
{
|
||||
[SwyxOverallState.Offline] = Color.FromArgb(0x9E, 0x9E, 0x9E), // grau
|
||||
[SwyxOverallState.ServerDown] = Color.FromArgb(0xE5, 0x39, 0x35), // rot
|
||||
[SwyxOverallState.Idle] = Color.FromArgb(0x43, 0xA0, 0x47), // gruen
|
||||
[SwyxOverallState.Ringing] = Color.FromArgb(0x1E, 0x88, 0xE5), // blau
|
||||
[SwyxOverallState.Dialing] = Color.FromArgb(0x00, 0xAC, 0xC1), // tuerkis
|
||||
[SwyxOverallState.InCall] = Color.FromArgb(0xFB, 0x8C, 0x00), // orange
|
||||
[SwyxOverallState.OnHold] = Color.FromArgb(0x8E, 0x24, 0xAA) // violett
|
||||
};
|
||||
|
||||
public static Icon For(SwyxOverallState state)
|
||||
{
|
||||
if (Cache.TryGetValue(state, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var icon = Render(Colors.TryGetValue(state, out var c) ? c : Color.Gray);
|
||||
Cache[state] = icon;
|
||||
return icon;
|
||||
}
|
||||
|
||||
private static Icon Render(Color color)
|
||||
{
|
||||
// 32x32 zeichnen und Windows herunterskalieren lassen — sieht auf
|
||||
// hochaufloesenden Anzeigen besser aus als 16x16.
|
||||
const int size = 32;
|
||||
using var bitmap = new Bitmap(size, size);
|
||||
using (var g = Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.Clear(Color.Transparent);
|
||||
|
||||
using (var brush = new SolidBrush(color))
|
||||
{
|
||||
g.FillEllipse(brush, 1, 1, size - 3, size - 3);
|
||||
}
|
||||
|
||||
// Hoerer: ein dicker Bogen mit runden Enden liest sich bei kleiner
|
||||
// Darstellung zuverlaessiger als eine ausmodellierte Silhouette.
|
||||
using var pen = new Pen(Color.White, 5f)
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
g.DrawArc(pen, 9f, 9f, 14f, 14f, 130f, 200f);
|
||||
}
|
||||
|
||||
// Icon.FromHandle borgt sich das Handle nur; ueber Clone entsteht eine
|
||||
// eigenstaendige Kopie, damit das HICON sofort freigegeben werden kann.
|
||||
var hIcon = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
using var borrowed = Icon.FromHandle(hIcon);
|
||||
return (Icon)borrowed.Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.DestroyIcon(hIcon);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DisposeAll()
|
||||
{
|
||||
foreach (var icon in Cache.Values)
|
||||
{
|
||||
icon.Dispose();
|
||||
}
|
||||
|
||||
Cache.Clear();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="SwyxTray.app" />
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- Bewusst asInvoker: die App muss in derselben Sitzung und mit
|
||||
demselben Integritaetslevel wie SwyxIt! laufen, sonst laesst
|
||||
COM keine Verbindung zum laufenden Client zu. -->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
</assembly>
|
||||
Reference in New Issue
Block a user