From 78b17a1350f698f7a576561ed187d174e67ff4df Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 21 Aug 2026 11:33:52 +0200 Subject: [PATCH] SwyxTray und Firefox-Plugin in das Repository aufnehmen Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang, Firefox-Erweiterung und Browser-Beispielclient. --- .gitignore | 43 ++ .vscode/launch.json | 41 ++ .vscode/start-desktop.ps1 | 67 +++ .vscode/tasks.json | 77 +++ README.md | 603 +++++++++++++++++++++++ examples/browser-client.html | 184 +++++++ src/SwyxFFPlugin/README.md | 38 ++ src/SwyxFFPlugin/background.js | 82 +++ src/SwyxFFPlugin/manifest.json | 17 + src/SwyxTray/Log.cs | 67 +++ src/SwyxTray/NativeMethods.cs | 69 +++ src/SwyxTray/Program.cs | 64 +++ src/SwyxTray/Swyx/LineStateExtensions.cs | 87 ++++ src/SwyxTray/Swyx/PubCLMgrMessages.cs | 28 ++ src/SwyxTray/Swyx/SwyxClient.cs | 476 ++++++++++++++++++ src/SwyxTray/Swyx/SwyxModels.cs | 118 +++++ src/SwyxTray/SwyxTray.csproj | 33 ++ src/SwyxTray/TrayApplicationContext.cs | 506 +++++++++++++++++++ src/SwyxTray/TrayIcons.cs | 90 ++++ src/SwyxTray/Web/CommandExecutor.cs | 196 ++++++++ src/SwyxTray/Web/LocalWebSocketServer.cs | 398 +++++++++++++++ src/SwyxTray/Web/PluginTabChannel.cs | 98 ++++ src/SwyxTray/Web/Protocol.cs | 209 ++++++++ src/SwyxTray/Web/UiDispatcher.cs | 48 ++ src/SwyxTray/Web/WebSocketConfig.cs | 218 ++++++++ src/SwyxTray/Web/WebSocketHandshake.cs | 261 ++++++++++ src/SwyxTray/Web/WebSocketSession.cs | 247 ++++++++++ src/SwyxTray/app.manifest | 31 ++ 28 files changed, 4396 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/start-desktop.ps1 create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 examples/browser-client.html create mode 100644 src/SwyxFFPlugin/README.md create mode 100644 src/SwyxFFPlugin/background.js create mode 100644 src/SwyxFFPlugin/manifest.json create mode 100644 src/SwyxTray/Log.cs create mode 100644 src/SwyxTray/NativeMethods.cs create mode 100644 src/SwyxTray/Program.cs create mode 100644 src/SwyxTray/Swyx/LineStateExtensions.cs create mode 100644 src/SwyxTray/Swyx/PubCLMgrMessages.cs create mode 100644 src/SwyxTray/Swyx/SwyxClient.cs create mode 100644 src/SwyxTray/Swyx/SwyxModels.cs create mode 100644 src/SwyxTray/SwyxTray.csproj create mode 100644 src/SwyxTray/TrayApplicationContext.cs create mode 100644 src/SwyxTray/TrayIcons.cs create mode 100644 src/SwyxTray/Web/CommandExecutor.cs create mode 100644 src/SwyxTray/Web/LocalWebSocketServer.cs create mode 100644 src/SwyxTray/Web/PluginTabChannel.cs create mode 100644 src/SwyxTray/Web/Protocol.cs create mode 100644 src/SwyxTray/Web/UiDispatcher.cs create mode 100644 src/SwyxTray/Web/WebSocketConfig.cs create mode 100644 src/SwyxTray/Web/WebSocketHandshake.cs create mode 100644 src/SwyxTray/Web/WebSocketSession.cs create mode 100644 src/SwyxTray/app.manifest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22f261d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# --- Secrets --- +.env +.env.* +!.env.example + +# --- OS --- +.DS_Store +Thumbs.db + +# --- IDE --- +.idea/ +*.iml +*.iws +*.ipr +*.code-workspace + +# --- Java / Maven --- +target/ +*.class +*.jar +*.war +*.log +.mvn/wrapper/maven-wrapper.jar + +# --- Node --- +node_modules/ +node/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# --- Vaadin / Vite --- +.vaadin/ +.vite/ +*.tsbuildinfo +tsconfig.tsbuildinfo +core/vite.generated.ts +core/src/main/frontend/generated/ + +# --- .NET (SwyxTray) --- +bin/ +obj/ +*.user diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6984204 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Die App hat kein Fenster: sichtbar ist nur das Symbol im Infobereich der + // Taskleiste, alles Weitere steht im Protokoll unter + // %LOCALAPPDATA%\SwyxTray\swyxtray.log. + "version": "0.2.0", + "configurations": [ + { + // Der Standardweg — funktioniert auch, wenn VS Code remote verbunden + // ist: eine geplante Aufgabe startet die App in der Desktop-Sitzung des + // angemeldeten Benutzers (nur dort gibt es eine Taskleiste fuer das + // Symbol), danach haengt sich der Debugger an den Prozess. + "name": "SwyxTray debuggen (Desktop-Sitzung)", + "type": "coreclr", + "request": "attach", + "preLaunchTask": "SwyxTray im Desktop starten (Debug)", + "processName": "SwyxTray.exe" + }, + { + // Direkter Start durch den Debugger. Bei lokal laufendem VS Code + // gleichwertig; bei Remote-Verbindung landet der Prozess in der + // Sitzung des VS-Code-Servers — die App laeuft dann, aber ohne + // sichtbares Tray-Symbol (dort existiert keine Taskleiste). + "name": "SwyxTray starten (direkt, ohne Tray bei Remote)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build (Debug)", + "program": "${workspaceFolder}/src/SwyxTray/bin/x64/Debug/net10.0-windows/win-x64/SwyxTray.exe", + "args": ["--verbose"], + "cwd": "${workspaceFolder}/src/SwyxTray", + "console": "internalConsole", + "stopAtEntry": false + }, + { + // Fuer eine bereits laufende Instanz, etwa aus dem Autostart. + "name": "An laufende SwyxTray anhaengen", + "type": "coreclr", + "request": "attach", + "processName": "SwyxTray.exe" + } + ] +} diff --git a/.vscode/start-desktop.ps1 b/.vscode/start-desktop.ps1 new file mode 100644 index 0000000..dd12d34 --- /dev/null +++ b/.vscode/start-desktop.ps1 @@ -0,0 +1,67 @@ +# Startet SwyxTray in der Desktop-Sitzung des angemeldeten Benutzers. +# +# Hintergrund: Laeuft VS Code remote (SSH, Tunnel), startet dessen Debugger +# Prozesse in der Sitzung des VS-Code-Servers - meist Sitzung 0, und dort gibt +# es keine Taskleiste, also auch kein Tray-Symbol. Eine geplante Aufgabe ohne +# /RU laeuft dagegen mit dem interaktiven Token des Benutzers und landet damit +# auf dem Desktop. Braucht keine Administratorrechte. +# +# Wird von der Aufgabe "SwyxTray im Desktop starten (Debug)" aufgerufen; die +# Einzelinstanz-Sperre und der Build sind dann schon erledigt (dependsOn). +# +# Bewusst KEIN $ErrorActionPreference = 'Stop': Die Aufgabe leitet stderr um, +# und in Windows PowerShell 5.1 wird dann jede stderr-Zeile eines nativen +# Programms zu einem ErrorRecord - mit 'Stop' bricht das Skript an der Stelle +# mit einer irrefuehrenden Meldung ab. Stattdessen wird nach jedem +# schtasks-Aufruf $LASTEXITCODE geprueft. +param( + [Parameter(Mandatory = $true)][string]$Exe +) + +$taskName = 'SwyxTray.F5' + +if (-not (Test-Path $Exe)) { + Write-Host "FEHLER: Nicht gefunden: $Exe - erst bauen." + exit 1 +} + +# Ein soeben beendeter Prozess ist in Get-Process noch kurz als Leiche +# sichtbar. Damit die Warteschleife nicht darauf hereinfaellt, zaehlt nur ein +# PID, der vorher nicht da war. +$before = @(Get-Process SwyxTray -ErrorAction SilentlyContinue | ForEach-Object Id) + +# /ST ist bei /SC ONCE Pflicht, der Zeitpunkt ist aber ohne Belang: +# gestartet wird sofort per /Run, danach wird die Aufgabe wieder entfernt. +$created = schtasks /Create /F /TN $taskName /TR "`"$Exe`" --verbose" /SC ONCE /ST 23:59 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Host "FEHLER: Geplante Aufgabe konnte nicht angelegt werden: $created" + exit 1 +} + +$run = schtasks /Run /TN $taskName 2>&1 +if ($LASTEXITCODE -ne 0) { + schtasks /Delete /F /TN $taskName 2>&1 | Out-Null + Write-Host "FEHLER: Geplante Aufgabe konnte nicht gestartet werden: $run" + exit 1 +} + +# Die Aufgabe darf erst weg, wenn sie die EXE gestartet hat - /Run ist +# asynchron, ein zu fruehes /Delete wuerde den Start abwuergen. +$process = $null +foreach ($i in 1..60) { + $process = Get-Process SwyxTray -ErrorAction SilentlyContinue | + Where-Object { $before -notcontains $_.Id -and -not $_.HasExited } | + Select-Object -First 1 + if ($process) { break } + Start-Sleep -Milliseconds 250 +} + +schtasks /Delete /F /TN $taskName 2>&1 | Out-Null + +if (-not $process) { + Write-Host "FEHLER: SwyxTray ist nicht gestartet - Protokoll unter %LOCALAPPDATA%\SwyxTray pruefen." + exit 1 +} + +Write-Host "SwyxTray laeuft (PID $($process.Id), Sitzung $($process.SessionId))." +exit 0 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..336d8a6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,77 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build (Debug)", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/SwyxTray/SwyxTray.csproj", + "-c", + "Debug", + // Das Paket Swyx.Client.ClmgrAPI waehlt die Interop-Assembly ueber + // $(Platform) und bricht bei AnyCPU ab. Die csproj setzt x64 bereits + // als Vorgabe; hier steht es noch einmal, damit die Aufgabe auch dann + // uebersetzt, wenn jemand die Vorgabe aendert. + "-p:Platform=x64", + "-consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { "kind": "build", "isDefault": true } + }, + { + "label": "build (Release)", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/SwyxTray/SwyxTray.csproj", + "-c", + "Release", + "-p:Platform=x64", + "-consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + // Beendet eine laufende Instanz — egal in welcher Sitzung. Ohne das + // schluege ein Start still fehl: die Einzelinstanz-Sperre laesst den + // zweiten Prozess kommentarlos enden. + "label": "SwyxTray beenden", + "command": "powershell", + "type": "process", + "args": [ + "-NoProfile", + "-Command", + // Wait-Process: erst zurueckkehren, wenn der Prozess wirklich weg ist — + // sonst sieht der nachfolgende Start noch die Leiche in Get-Process. + // Das abschliessende 'exit 0' ist Pflicht: findet Get-Process keinen + // Prozess, steht $? auf false, und powershell -Command macht daraus + // sonst Exit-Code 1 — die Kette briche ab, obwohl nichts zu tun war. + "$p = Get-Process SwyxTray -ErrorAction SilentlyContinue; if ($p) { $p | Stop-Process -Force; $p | Wait-Process -Timeout 5 -ErrorAction SilentlyContinue }; exit 0" + ], + "problemMatcher": [] + }, + { + // Startet die App in der DESKTOP-Sitzung des angemeldeten Benutzers — + // noetig, wenn VS Code remote verbunden ist. Warum, steht im Skript. + "label": "SwyxTray im Desktop starten (Debug)", + "dependsOrder": "sequence", + "dependsOn": ["SwyxTray beenden", "build (Debug)"], + "command": "powershell", + "type": "process", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/.vscode/start-desktop.ps1", + "-Exe", + "${workspaceFolder}/src/SwyxTray/bin/x64/Debug/net10.0-windows/win-x64/SwyxTray.exe" + ], + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..16a0ff0 --- /dev/null +++ b/README.md @@ -0,0 +1,603 @@ +# SwyxTray + +Windows-Anwendung **ohne Fenster**, die nur ein Symbol im Infobereich der +Taskleiste (unten rechts) anzeigt und sich per Swyx Client SDK mit dem lokal +laufenden SwyxIt!-Client verbindet. + +## Was die App tut + +- Verbindet sich beim Start mit dem **Client Line Manager** (`CLMgr.exe`) der + aktuellen Windows-Sitzung — es wird kein zweiter Client gestartet, sondern der + bereits laufende mitbenutzt. +- Zeigt den Leitungszustand über die Farbe des Tray-Symbols: + + | Farbe | Zustand | + |-----------|----------------------------------| + | grau | SwyxIt! nicht verbunden | + | rot | SwyxServer nicht erreichbar | + | grün | bereit, keine belegte Leitung | + | blau | eingehender Ruf | + | türkis | Verbindungsaufbau (ausgehend) | + | orange | Gespräch aktiv | + | violett | Gespräch gehalten | + +- Blendet bei eingehenden Rufen eine Sprechblase mit Name und Rufnummer ein. +- Stellt einen **WebSocket-Zugang auf 127.0.0.1** bereit, über den eine im + Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug + jede Zustandsänderung zugestellt bekommt (siehe unten). +- Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*, + *Lese Tabs*, *Tab öffnen*, *Tab schliessen*, *Neu verbinden*, + *Protokoll öffnen*, *Beenden*. Doppelklick zeigt den Status als Sprechblase. + + Die drei Tab-Punkte laufen über den Tab-Verwaltungskanal zum Firefox-Plugin + (siehe unten); ohne verbundenes Plugin (oder mit `PluginPort: 0`) bleiben + sie ohne Ergebnis bzw. grau. *Lese Tabs* holt die Titel der offenen Tabs + und zeigt sie als Sprechblase — die vollständige Liste samt Adressen landet + im Protokoll. *Tab öffnen* öffnet einen Tab mit `https://www.assecutor.de`, + *Tab schliessen* schließt alle Tabs, deren Titel „Assecutor Data Service + GmbH" enthält — als Komposition aus `list` und `close` je Treffer, wie beim + `focus`-Kommando (beide Werte sind Konstanten in + [TrayApplicationContext.cs](src/SwyxTray/TrayApplicationContext.cs)). + +## Verwendetes SDK + +Offizielles NuGet-Paket von Enreach: + +```xml + +``` + +Das Paket enthält die Interop-Wrapper der ClientLineManager-COM-API +(`IpPbx.CLMgrLib`) und wählt über `$(Platform)` automatisch `Interop.CLMgr.dll` +(x86) bzw. `Interop64.CLMgr.dll` (x64). **Deshalb muss `Platform` gesetzt sein +— bei `AnyCPU` bricht der Build mit einem Fehler des Pakets ab.** + +Die Version 14.21.0 passt zur hier installierten SwyxIt!-Version 14.21.4345.0. +Bei einem Client-Update die Paketversion mitziehen. + +Genutzte Schnittstellen: + +- `IClientLineMgrDisp` — `DispInit`, `DispRegisterUser`, `DispNumberOfLines`, + `DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`, + `DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser` +- `IClientLineDisp` — `DispState`, `DispPeerNumber`, `DispPeerName` +- `IClientLineMgrEventsPub_Event` — `PubOnLineMgrNotification(msg, param)` +- `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung + +## Aufbau + +| Datei | Zweck | +|---|---| +| [Program.cs](src/SwyxTray/Program.cs) | Einstieg, STA-Thread, Einzelinstanz-Sperre, Fehlerprotokollierung | +| [TrayApplicationContext.cs](src/SwyxTray/TrayApplicationContext.cs) | `NotifyIcon`, Kontextmenü, Sprechblasen | +| [TrayIcons.cs](src/SwyxTray/TrayIcons.cs) | erzeugt die Symbole zur Laufzeit (keine .ico-Dateien nötig) | +| [Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs) | CLMgr-Verbindung, Ereignisbehandlung, Zustandsabfrage | +| [Swyx/SwyxModels.cs](src/SwyxTray/Swyx/SwyxModels.cs) | `SwyxSnapshot`, `SwyxLineInfo`, Gesamtzustand | +| [Swyx/LineStateExtensions.cs](src/SwyxTray/Swyx/LineStateExtensions.cs) | Einordnung und deutscher Klartext der `CLMgrLineStates` | +| [Swyx/PubCLMgrMessages.cs](src/SwyxTray/Swyx/PubCLMgrMessages.cs) | Meldungsnummern aus `CLMgrPubTypes.h` | +| [Web/LocalWebSocketServer.cs](src/SwyxTray/Web/LocalWebSocketServer.cs) | Listener auf 127.0.0.1 und ::1, Verbindungsverwaltung, Verteilen der Zustände | +| [Web/WebSocketHandshake.cs](src/SwyxTray/Web/WebSocketHandshake.cs) | HTTP-Upgrade, Prüfung von Token, Origin und Host | +| [Web/WebSocketSession.cs](src/SwyxTray/Web/WebSocketSession.cs) | eine Verbindung: Empfangsschleife, serialisiertes Senden | +| [Web/CommandExecutor.cs](src/SwyxTray/Web/CommandExecutor.cs) | Kommandos → `SwyxClient`, mit Zeitgrenze | +| [Web/UiDispatcher.cs](src/SwyxTray/Web/UiDispatcher.cs) | Sprung aus den Netzwerk-Threads in den UI-Thread | +| [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten | +| [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins | +| [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs | +| [src/SwyxFFPlugin/](src/SwyxFFPlugin/) | die Firefox-Erweiterung: [manifest.json](src/SwyxFFPlugin/manifest.json) und [background.js](src/SwyxFFPlugin/background.js) | +| [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` | + +### Zwei Entwurfsentscheidungen + +**Ereignisgesteuert statt Polling.** `PubOnLineMgrNotification` liefert jede +Zustandsänderung sofort. Bei *jeder* Meldung wird der komplette Zustand neu +gelesen — das kostet nur wenige COM-Zugriffe und macht die Anzeige unabhängig +davon, ob eine Meldungsnummer richtig zugeordnet ist. `PubCLMgrMessages` steckt +nämlich nur im C-Header des SDK, nicht in der TypeLib. Zusätzlich läuft alle +5 Sekunden ein Sicherheitszyklus, der einen beendeten CLMgr erkennt und die +Verbindung wiederherstellt. + +**Alles im UI-Thread.** CLMgr ist ein STA-COM-Server; der WinForms-Thread ist +STA und hat bereits eine Nachrichtenschleife. Dadurch werden die COM-Ereignisse +automatisch dorthin gemarshallt — kein manuelles Marshalling, keine Sperren. + +### `CoInitializeSecurity` ist nicht optional + +`CLMgr.exe` ist ein eigener Prozess. Beim Anmelden der Ereignissenke ruft er in +diesen Prozess **zurück** — und ohne eigene Sicherheitsvorgabe weist COM diesen +Rückruf mit `E_ACCESSDENIED` ab. [NativeMethods.InitializeComSecurity](src/SwyxTray/NativeMethods.cs) +setzt deshalb vor dem ersten COM-Aufruf `RPC_C_AUTHN_LEVEL_NONE` / +`RPC_C_IMP_LEVEL_IDENTIFY`. Das betrifft nur diesen Prozess, der außer der +Ereignissenke nichts nach außen anbietet. Wer es enger fassen will, probiert +`RPC_C_AUTHN_LEVEL_CONNECT` (2) — das genügt, solange CLMgr in derselben Sitzung +unter demselben Konto läuft. + +Schlägt das Anmelden trotzdem fehl, ist das **kein** Abbruchgrund: die App bleibt +verbunden und schaltet den Abfragezyklus von 5 s auf 1 s herunter. + +## Zugriff aus dem Browser (WebSocket) + +Die App hört auf `ws://127.0.0.1:17654/` und spricht dort JSON — eine +Nachricht je Frame. Über dieselbe Verbindung laufen beide Richtungen: die Seite +schickt Kommandos, die App schickt unaufgefordert jede Zustandsänderung. + +### Einrichten + +Beim ersten Start entsteht `%LOCALAPPDATA%\SwyxTray\websocket.json`: + +```json +{ + "Enabled": true, + "Port": 17654, + "PluginPort": 17655, + "Token": "E59BF4…", + "AllowedOrigins": [], + "MaxConnections": 8, + "AllowRemoteAccess": false +} +``` + +Solange die Prüfungen abgeschaltet sind (siehe unten), sind `Token` und +`AllowedOrigins` ohne Wirkung — für einen ersten Test ist an dieser Datei nichts +zu tun. Der Rest dieses Abschnitts gilt für den eingeschalteten Zustand. + +Das Token wird zufällig erzeugt, die Liste der Origins bleibt leer — **ohne +Eintrag wird jede Verbindung aus einem Browser abgewiesen.** Das ist Absicht, +denn ein offener Port ohne diese Schranke hieße: jede beliebige Seite, die +jemand im Browser öffnet, kann Anrufe auslösen. Einzutragen ist der Ursprung +der aufrufenden Seite, also z. B. `https://crm.example.local` — ohne Pfad. Nach +einer Änderung muss die App neu gestartet werden. + +*WebSocket-Zugang → Verbindungsdaten kopieren* im Kontextmenü legt Adresse und +Token in die Zwischenablage. + +### Verbinden + +```js +const ws = new WebSocket("ws://127.0.0.1:17654/?token=…", ["swyxtray.v1"]); +``` + +Das Token darf statt in der URL auch als zweites Unterprotokoll stehen +(`["swyxtray.v1", "token.…"]`) — nützlich, wenn das Geheimnis nicht in +Adresszeilen und Verlaufslisten auftauchen soll. Die WebSocket-API des Browsers +kennt keine eigenen Header, deshalb diese beiden Wege. + +Ein vollständiges Beispiel mit Wiederverbindung steht in +[examples/browser-client.html](examples/browser-client.html). + +### Firefox-Plugin (SwyxFFPlugin): der Tab-Verwaltungskanal + +Für das Firefox-Plugin hört die App zusätzlich auf einem **zweiten Port** — +voreingestellt **17655** (`PluginPort` in `websocket.json`, `0` schaltet ihn +ab; in einer bestehenden Datei ohne diesen Eintrag gilt automatisch 17655). + +Auf diesem Port läuft **ausschließlich die Tab-Verwaltung** — keine +Snapshots, keine Anruf-Ereignisse, keine Telefonie-Kommandos (sie werden mit +`ok: false` abgewiesen). Nach dem `hello` ist die Richtung umgekehrt zum +Hauptport: **SwyxTray fragt, das Plugin antwortet.** Es gibt drei Aufträge, +jeder wird mit einem `tabresult` samt unveränderter `id` beantwortet: + +**Offene Tabs auslesen:** + +```json +→ an das Plugin: {"type":"tab","id":1,"action":"list"} +← vom Plugin: {"cmd":"tabresult","id":1,"ok":true, + "tabs":[{"id":42,"title":"Kundenakte Muster GmbH", + "url":"https://crm.example.local/kunden/4711","active":false}]} +``` + +**Tab mit einer URL öffnen** (der neue Tab ist in Firefox von selbst vorn): + +```json +→ an das Plugin: {"type":"tab","id":2,"action":"open","url":"https://crm.example.local/kunden/4711"} +← vom Plugin: {"cmd":"tabresult","id":2,"ok":true,"tabId":43} +``` + +**Tab schließen:** + +```json +→ an das Plugin: {"type":"tab","id":3,"action":"close","tabId":42} +← vom Plugin: {"cmd":"tabresult","id":3,"ok":true} +``` + +Antwortet das Plugin binnen fünf Sekunden nicht (oder ist keines verbunden), +gilt der Auftrag als gescheitert; die darauf aufbauenden Kommandos des +Hauptports (`focus`, `tabs`, `opentab`, `closetab`) enden dann mit +`ok: false`. Vom Plugin aus sind nur `tabresult` und `ping` zulässig. Ein +„Tab aktivieren“ kennt der Kanal bewusst nicht — beim `focus`-Kommando holt +SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden +Tab per `close` schließen und die URL per `open` neu öffnen. + +Das Plugin selbst liegt in [src/SwyxFFPlugin/](src/SwyxFFPlugin/) — +[background.js](src/SwyxFFPlugin/background.js) setzt genau diese drei +Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch +verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft. +Installation zum Erproben: `about:debugging#/runtime/this-firefox` → +*Temporäres Add-on laden…* → die `manifest.json` des Ordners wählen (Näheres +im [README des Plugins](src/SwyxFFPlugin/README.md)). + +Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das +Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der +Origin des Plugins stehen. Firefox sendet dafür `moz-extension://`, +wobei die UUID je Installation zufällig vergeben wird — sie steht in +`about:debugging` unter „Interne UUID“ der Erweiterung. + +### Kommandos (Seite → App) + +| Kommando | Felder | Wirkung | +|---|---|---| +| `call` | `number` | wählt auf einer freien Leitung | +| `answer` | `line` (optional) | nimmt an; ohne Angabe die klingelnde Leitung | +| `hangup` | `line` (optional) | legt auf; ohne Angabe die belegte Leitung | +| `hold` | `line` (optional) | stellt das Gespräch in die Warteschleife | +| `focus` | `url`, `title` (optional) | aktiviert oder öffnet den passenden Firefox-Tab über das Plugin | +| `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus | +| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab | +| `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id | +| `status` | — | fordert den vollen Zustand an | +| `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf | +| `ping` | — | Lebenszeichen | + +`id` ist frei wählbar und steht in der Antwort wieder da — anders als bei HTTP +gibt es keine eingebaute Zuordnung von Anfrage und Antwort. + +```json +→ {"id":7,"cmd":"call","number":"+49 30 1234567"} +← {"type":"result","id":7,"ok":true,"line":2} +``` + +Leitungen werden nach außen **1-basiert** gezählt, so wie sie im Kontextmenü und +in SwyxIt! erscheinen. Rufnummern dürfen Leerzeichen, Bindestriche, Punkte und +Klammern enthalten; alles andere außer Ziffern, führendem `+`, `*` und `#` wird +abgewiesen, statt es an CLMgr weiterzureichen. + +Ein `ok: true` auf `call` heißt nur, dass CLMgr den Auftrag angenommen hat. Das +Wählen läuft asynchron — ob der Ruf zustande kommt, steht erst in den folgenden +Zustandsmeldungen. + +`focus` (seit Protokollversion 3, ab Version 5 ausschließlich über das +**Firefox-Plugin**, siehe unten): war ein passender Tab schon offen — sein +Titel enthält den `title` oder seine Adresse ist die `url` —, wird er +geschlossen und die `url` neu geöffnet (`"focused": true`), sonst nur neu +geöffnet (`"focused": false`). Ist kein Plugin verbunden oder antwortet es +nicht, endet das Kommando mit `ok: false` — einen anderen Weg, eine Seite +anzuzeigen, gibt es nicht. Die `url` ist Pflicht, `title` dient nur dem +Tab-Abgleich; erlaubt sind nur absolute `http(s)`-Adressen. + +```json +→ {"id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://crm.example.local/kunden/4711"} +← {"type":"result","id":9,"ok":true,"focused":false} +``` + +`tabs`, `opentab`, `closetab` (seit Protokollversion 6): die drei Aufträge des +Tab-Verwaltungskanals stehen damit auch Webseiten offen — SwyxTray reicht sie +unverändert an das Firefox-Plugin durch. Ist kein Plugin verbunden oder +antwortet es nicht binnen fünf Sekunden, endet das Kommando mit `ok: false`. +Für `opentab` gilt dieselbe Regel wie bei `focus`: nur absolute +`http(s)`-Adressen. Die `tabId` für `closetab` stammt aus einer vorherigen +`tabs`-Antwort; nach Öffnen oder Schließen verschickt die App **keine** neue +Tab-Liste von selbst — wer sie aktuell braucht, fragt mit `tabs` nach: + +```json +→ {"id":10,"cmd":"tabs"} +← {"type":"result","id":10,"ok":true, + "tabs":[{"id":42,"title":"Kundenakte Muster GmbH", + "url":"https://crm.example.local/kunden/4711","active":false}]} + +→ {"id":11,"cmd":"opentab","url":"https://crm.example.local/kunden/4711"} +← {"type":"result","id":11,"ok":true,"tabId":43} + +→ {"id":12,"cmd":"closetab","tabId":43} +← {"type":"result","id":12,"ok":true} +``` + +### Meldungen (App → Seite) + +Direkt nach dem Verbinden kommen `hello` und ein `snapshot`, danach ein +`snapshot` bei **jeder** Zustandsänderung. Die Seite muss also nie pollen und +nie selbst Zustand mitführen: + +```json +{"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing", + "statusText":"Eingehender Ruf","user":"…","server":"…", + "lines":[{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt", + "peer":"Muster GmbH (+493012345)","peerNumber":"+493012345", + "peerName":"Muster GmbH","busy":true,"selected":true}]} +``` + +`overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt. + +Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene +Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände +vergleichen, um einen eingehenden Ruf zu erkennen: + +| `event` | Bedeutung | +|---|---| +| `incoming` | Leitung beginnt zu klingeln (auch Anklopfen) | +| `outgoing` | Wahl eines ausgehenden Rufs beginnt | +| `connected` | Gespräch steht; `direction` sagt `incoming`/`outgoing`, sofern erkennbar | +| `ended` | Leitung ist wieder frei; Peer-Angaben aus dem letzten belegten Zustand | + +```json +{"type":"call","event":"incoming","line":1, + "peer":"Muster GmbH (+493012345)","peerNumber":"+493012345","peerName":"Muster GmbH"} +``` + +Die Reihenfolge ist garantiert: erst der `snapshot`, unmittelbar danach die +daraus abgeleiteten `call`-Ereignisse. Ein typischer Ablauf für ein +angenommenes Gespräch ist also `incoming` → `connected` (mit +`"direction":"incoming"`) → `ended`, jeweils mit einem `snapshot` davor. +Wählt die Seite selbst per Kommando `call`, folgen `outgoing` → `connected` → +`ended` auf demselben Weg. Diese Ereignisse gibt es seit Protokollversion 2 +(Feld `protocol` im `hello`). + +### Was den Zugang absichert + +> **Zur Zeit greift davon nur die Bindung.** Token-, Origin- und Host-Prüfung +> sind für die Erprobung abgeschaltet, und `AllowRemoteAccess` steht auf `true` +> — siehe die beiden folgenden Abschnitte. Der Rest dieses Abschnitts +> beschreibt den eingeschalteten Zustand. + +Vier Schranken, die zusammen wirken: + +1. **Gebunden nur an 127.0.0.1 und ::1.** Von außen ist der Port nicht + erreichbar, und die Windows-Firewall fragt beim Start nichts nach. Diese + Schranke fällt mit `AllowRemoteAccess` (siehe unten). +2. **Token** in jedem Handshake, zeitkonstant verglichen. +3. **Origin-Allowlist.** Für WebSockets gilt die Same-Origin-Policy *nicht* — + der Browser blockiert nichts, es gibt keinen Preflight. Diese Prüfung ist + damit nicht Härtung, sondern die eigentliche Schranke gegen fremde Seiten. + Fehlt der Origin-Header ganz, ist der Aufrufer kein Browser; das ist + zulässig, weil das Token bereits nachgewiesen wurde. Eine Webseite kann den + Header nicht weglassen. +4. **Host-Prüfung** auf `127.0.0.1`, `localhost` bzw. `[::1]`. Ohne sie könnte + eine Seite einen eigenen Namen auf 127.0.0.1 zeigen lassen (DNS-Rebinding). + Entfällt ebenfalls mit `AllowRemoteAccess`. + +### Zugriff von einem anderen Rechner + +Mit `"AllowRemoteAccess": true` bindet die App statt an die Loopback-Adressen an +**alle Schnittstellen** (`0.0.0.0` und `::`) und ist damit im lokalen Netz +erreichbar: + +```js +new WebSocket("ws://192.168.180.135:17654/?token=…"); +``` + +Die erreichbaren Adressen stehen beim Start im Protokoll, und +*Verbindungsdaten kopieren* liefert dann ebenfalls die LAN-Adresse statt +127.0.0.1. + +Drei Dinge ändern sich damit: + +- **Die Host-Prüfung entfällt.** Unter welchem Namen oder welcher Adresse ein + entfernter Rechner die App anspricht, kann sie nicht wissen. Der Schutz gegen + DNS-Rebinding ruht damit allein auf Token und Origin-Liste. +- **Die Windows-Firewall redet mit.** Ist sie aktiv, braucht es eine + eingehende Regel, sonst kommt vom anderen Rechner nichts an: + + ```powershell + New-NetFirewallRule -DisplayName "SwyxTray WebSocket" -Direction Inbound ` + -Protocol TCP -LocalPort 17654 -Action Allow + ``` + + Das erfordert Administratorrechte — die einzige Stelle des Vorhabens, an der + sie nötig sind, und ein Grund, warum Loopback die Voreinstellung bleibt. +- **`ws://` gilt aus der Ferne nicht mehr als vertrauenswürdig.** Die + Ausnahme für Mixed Content gilt nur für Loopback. Eine **HTTPS**-Seite auf dem + anderen Rechner kann `ws://192.168.…` also nicht öffnen — für den Test muss + die Seite über `http://` kommen, oder es braucht `wss://` mit einem + Zertifikat, das die App derzeit nicht anbietet. + +Wer den Zugang nicht nur zum Testen aus dem Netz erreichbar macht, sollte sich +bewusst sein: Token und Origin-Liste sind dann das Einzige, was zwischen dem +Netz und dem Telefon des Benutzers steht. + +### Zur Zeit sind die Prüfungen abgeschaltet + +**Für die Erprobung entfallen Token-, Origin- und Host-Prüfung — in jedem +Build.** Eine Seite verbindet sich ohne jede Einrichtung, auch direkt aus dem +Dateisystem, wo der Browser den Origin `null` sendet: + +```js +new WebSocket("ws://127.0.0.1:17654/"); +``` + +Die Konfigurationsdatei wird nur noch für Port, Verbindungsgrenze und +`AllowRemoteAccess` gelesen; ein fehlendes Token verhindert den Start nicht. + +Zusammen mit dem eingeschalteten Zugriff aus dem Netz heißt das: **jeder im +selben Netz kann über diesen Anschluss telefonieren und mitlesen, wer anruft.** +In diesem Zustand nicht ausliefern. + +Erkennbar ist das am Protokoll und am Kontextmenü, das dann +*WebSocket: Port 17654, 1 Verbindung(en) — im Netz — OHNE PRUEFUNG* zeigt: + +``` +[INF] WebSocket-Zugang aus dem Netz erreichbar unter ws://192.168.180.135:17654/ + — OHNE PRUEFUNG: Token, Origin und Host werden nicht geprueft. Jeder im + selben Netz kann telefonieren und mitlesen. Nicht in diesem Zustand + ausliefern. +``` + +Wieder einschalten lässt sich alles mit **einer Zeile** in +[Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) — die Prüfungen +selbst stehen unverändert in +[Web/WebSocketHandshake.cs](src/SwyxTray/Web/WebSocketHandshake.cs): + +```csharp +public static readonly bool SecurityDisabled = true; // <- zur Zeit +``` + +Der dort im Kommentar hinterlegte Ersatz macht daraus wieder ein +`#if DEBUG` — dann prüft der Release-Build alles, während sich der Debug-Build +weiterhin ohne Einrichtung erproben lässt. + +### Was im Browser noch dazwischenkommen kann + +`ws://127.0.0.1` gilt als vertrauenswürdiger Ursprung und wird auch aus einer +HTTPS-Seite heraus nicht als Mixed Content blockiert. Neuere Chrome-Versionen +beschränken allerdings Zugriffe aus dem öffentlichen ins lokale Netz (*Local +Network Access*) und fragen den Benutzer — das lässt sich per +Unternehmensrichtlinie freigeben, aber nicht durch einen Antwort-Header lösen. +**Vor dem Rollout mit der IT klären.** + +Browser lösen `localhost` häufig zuerst nach `::1` auf. Die App hört deshalb auf +beiden Loopback-Adressen; sonst funktionierte `ws://127.0.0.1:…`, während +`ws://localhost:…` ohne erkennbaren Grund scheiterte. + +### Zwei Entwurfsentscheidungen + +**Kein `HttpListener`.** Der läuft über http.sys und braucht auch für +127.0.0.1 eine einmalige URL-Reservierung mit Administratorrechten +(`netsh http add urlacl`). Die App soll ohne solche Rechte auskommen, deshalb +ein schlichter `TcpListener`. Vom WebSocket-Protokoll bleibt dabei nur der +HTTP-Upgrade von Hand zu schreiben — Rahmen, Maskierung, Ping/Pong und der +Schließvorgang kommen von `WebSocket.CreateFromStream` aus der +Klassenbibliothek. + +**Kommandos laufen über den UI-Thread.** CLMgr ist ein STA-COM-Server, und die +App bündelt alle COM-Zugriffe im WinForms-Thread. Der +[UiDispatcher](src/SwyxTray/Web/UiDispatcher.cs) schleust jedes Kommando aus dem +Netzwerk-Thread dorthin, mit einer Zeitgrenze von 10 Sekunden: bliebe die +Nachrichtenschleife hängen, fröre die Verbindung sonst stumm ein. + +## Bauen und Starten + +```powershell +cd src\SwyxTray +dotnet build -c Release +.\bin\x64\Release\net10.0-windows\win-x64\SwyxTray.exe --verbose +``` + +`--verbose` schreibt zusätzlich jede CLMgr-Meldung ins Protokoll — für die +erste Inbetriebnahme empfehlenswert. + +### Aus VS Code (F5) + +Die Startkonfiguration **„SwyxTray debuggen (Desktop-Sitzung)"** in +[.vscode/launch.json](.vscode/launch.json) baut, startet und hängt den Debugger +an. Sie startet die App über eine geplante Aufgabe +([.vscode/start-desktop.ps1](.vscode/start-desktop.ps1)) — nötig, weil bei +einer Remote-Verbindung (SSH, Tunnel) der Debugger Prozesse sonst in der +Sitzung des VS-Code-Servers startet, meist Sitzung 0. Dort gibt es keine +Taskleiste, die App läuft dann ohne sichtbares Tray-Symbol. Die geplante +Aufgabe läuft dagegen mit dem interaktiven Token des Benutzers und landet auf +dem Desktop; danach hängt sich der Debugger per Prozessname an. Eine bereits +laufende Instanz wird vorher beendet, sonst griffe die Einzelinstanz-Sperre. + +Autostart: eine Verknüpfung auf die EXE in +`shell:startup` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`) +ablegen. Kein Dienst, keine Administratorrechte. + +### Voraussetzungen + +- SwyxIt! muss laufen (getestet gegen 14.21.4345.0). +- **.NET-10-Desktop-Laufzeit.** Auf diesem Rechner ist nur .NET 10 installiert, + deshalb steht `net10.0-windows` in der csproj. Für .NET 8 einfach + `net8.0-windows` setzen. Alternativ ohne + Laufzeitabhängigkeit ausliefern: + + ```powershell + dotnet publish -c Release -r win-x64 --self-contained true + ``` + +- Die App läuft **in derselben Windows-Sitzung wie SwyxIt!** — als normale + Benutzeranwendung, nicht als Dienst (siehe unten). + +## Warum kein Windows-Dienst + +Ursprünglich war ein Windows-Dienst vorgesehen. Das funktioniert für diesen +Zweck nicht: ein Dienst läuft in **Session 0**, der SwyxIt!-Client und sein +`CLMgr.exe` in der Benutzersitzung (1, 2, …). Über die Sitzungsgrenze hinweg +lässt sich der laufende Client nicht ansprechen. Ein Dienst müsste dafür einen +Hilfsprozess per `CreateProcessAsUser` in die Benutzersitzung starten und mit +ihm über eine Named Pipe reden. Die Tray-Anwendung umgeht das Problem +vollständig, weil sie von vornherein in der richtigen Sitzung läuft. + +## Stand der Prüfung + +- **Übersetzung gegen das echte SDK: erfolgreich.** Alle verwendeten Typen und + Signaturen stammen aus `Interop64.CLMgr.dll` (Version 2.0.0.0) des Pakets und + wurden gegen die installierte TypeLib `CLMgr 2.0` + (`{F8E552F7-4C00-11D3-80BC-00105A653379}`, registriert auf + `C:\Program Files (x86)\Swyx\SwyxIt!\CLMgr.exe`) verifiziert. +**Gegen den echten, laufenden Client verifiziert** (SwyxIt! 14.21.4345.0): + +``` +[INF] SwyxTray gestartet (PID 3348, Sitzung 0, x64). +[INF] Mit CLMgr verbunden (UserId 0, Benutzer 'Assecutor, G.Timm', + Server '127.0.0.1', 4 Leitungen). +[DBG] Zustand: Idle | L1=LSInactive(0) * | L2=LSInactive(0) + | L3=LSInactive(0) | L4=LSInactive(0) +``` + +Bestätigt sind damit: COM-Aktivierung, `DispInit`, `DispRegisterUser`, Anmelden +der Ereignissenke, Auslesen aller vier Leitungen samt Zustand und ausgewählter +Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung +(keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll. + +**WebSocket-Zugang gegen die laufende App geprüft** (mit einem +`ClientWebSocket` als Gegenstelle): + +``` +[INF] WebSocket-Zugang bereit auf ws://127.0.0.1:17654/ +[INF] WebSocket-Anfrage von 127.0.0.1:51312 abgewiesen (403): Token fehlt oder ist falsch. +[INF] WebSocket-Anfrage von 127.0.0.1:51313 abgewiesen (403): Origin 'https://boese.example' + ist nicht freigegeben. +[INF] WebSocket 1 verbunden (127.0.0.1:51314, Origin (ohne Origin)). +[INF] WebSocket 2 verbunden ([::1]:64293, Origin https://test.example). +``` + +Bestätigt sind damit: Handshake samt Unterprotokoll-Aushandlung, Token- und +Origin-Prüfung in beide Richtungen, Erreichbarkeit über `127.0.0.1` *und* +`localhost`/`::1`, `hello` und `snapshot` beim Verbinden, die Kommandos `ping`, +`status`, `hangup`, das Abweisen ungültiger Rufnummern, unbekannter Kommandos +und kaputten JSONs sowie der Schließvorgang. + +Geprüft wurde außerdem der jetzige Zustand mit abgeschalteten Prüfungen: der +**Release-Build** nimmt eine Verbindung über die LAN-Adresse ohne Token und mit +fremdem Origin an. Zuvor, mit eingeschalteten Prüfungen, wies er dieselbe +Anfrage mit 403 ab, während der Debug-Build sie annahm — beide Zweige sind also +erprobt. + +Mit `"AllowRemoteAccess": true` lauscht die App auf `0.0.0.0:17654` und +`[::]:17654` (bestätigt über `Get-NetTCPConnection`); eine Verbindung über die +LAN-Adresse `ws://192.168.180.135:17654/` wird angenommen, dieselbe mit falschem +Token weiterhin abgewiesen. **Der Test lief vom selben Rechner aus** — dass ein +entfernter Rechner durchkommt, hängt zusätzlich an der Firewall. Auf dem +Testrechner ist sie in allen Profilen abgeschaltet, dort war keine Regel nötig. + +**Noch nicht am realen Verkehr geprüft:** Zustandswechsel während eines echten +Gesprächs — Klingeln, Annehmen, Halten, Auflegen — und damit die Sprechblase bei +eingehenden Rufen, der Farbwechsel des Symbols und das Verteilen der +Zustandsmeldungen an verbundene Seiten. Dafür ist ein Testanruf nötig; `call` +wurde bewusst nur mit ungültigen Nummern erprobt, um keinen echten Ruf +auszulösen. Ebenso ungeprüft ist das Symbol im Infobereich selbst, weil die +verwendete Konsole in Session 0 lief und dort keine Taskleiste existiert — und +damit auch die Beispielseite in einem echten Browser. + +### Zwei Hürden auf dem Weg dorthin + +Beide betrafen die Umgebung, nicht den Code, sind aber für die Auslieferung +relevant: + +1. **Smart App Control blockierte den COM-Proxy.** Jedes `QueryInterface` über + die Prozessgrenze lädt `C:\Program Files\Common Files\Swyx\CLMgrPs64.dll` in + den eigenen Prozess; genau diese Swyx-Datei wurde abgewiesen (`0x800711C7`, + CodeIntegrity 3033/3077/3118, *Enterprise signing level requirements*). + Das Abschalten von Smart App Control wirkt erst **nach einem Neustart** — + der Registry-Wert steht sofort auf `0`, `Win32_DeviceGuard` meldet aber bis + zum Reboot weiter Erzwingung. **Läuft beim Kunden WDAC, muss `CLMgrPs64.dll` + im Regelwerk freigegeben sein** — das trifft jede Anwendung, die den + Swyx-Proxy lädt. +2. **`IConnectionPoint::Advise` scheiterte mit `E_ACCESSDENIED`** — gelöst durch + `CoInitializeSecurity` (siehe oben). + +## Quellen + +- [INFO: SwyxIt! Client SDK — Enreach Help-Center](https://service.swyx.net/hc/en-gb/articles/360011686580-INFO-SwyxIt-Client-SDK) +- [SwyxIt! Client SDK — API-Referenz](https://clientsdk.swyx.engineering/) +- [IClientLineMgrEventsPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_events_pub.html) +- [IClientLineMgrPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_pub.html) +- [CLMgrPubTypes.h — PubCLMgrMessages](https://clientsdk.swyx.engineering/_c_l_mgr_pub_types_8h.html) +- [NuGet: Swyx.Client.ClmgrAPI](https://www.nuget.org/packages/Swyx.Client.ClmgrAPI) diff --git a/examples/browser-client.html b/examples/browser-client.html new file mode 100644 index 0000000..1c90e34 --- /dev/null +++ b/examples/browser-client.html @@ -0,0 +1,184 @@ + + + + +SwyxTray — WebSocket-Beispiel + + + + +

SwyxTray — WebSocket-Beispiel

+ +

+ Zur Zeit sind alle Prüfungen abgeschaltet — es genügt + ws://127.0.0.1:17654/ ohne Token, auch beim Öffnen dieser Seite + direkt aus dem Dateisystem. +

+

+ Von einem anderen Rechner aus: in + websocket.json "AllowRemoteAccess": true setzen, App + neu starten und die LAN-Adresse verwenden + (ws://192.168.x.y:17654/…). Diese Seite muss dann über + http:// ausgeliefert werden — aus einer HTTPS-Seite heraus + blockiert der Browser ws:// auf eine entfernte Adresse als Mixed + Content. Für Loopback gilt diese Ausnahme, für die LAN-Adresse nicht. +

+

+ Sind die Prüfungen wieder eingeschaltet, muss die Seite von einem Origin + geladen werden, der in %LOCALAPPDATA%\SwyxTray\websocket.json + unter AllowedOrigins steht, und die Adresse braucht das Token. + Über file:// sendet der Browser den Origin null — + dann muss dort "null" eingetragen sein. +

+ +
+ Verbindung + + +

Status: nicht verbunden

+
+ +
+ Steuerung + + + + + + +

+ + + +

+
+ +
+ Tabs (Firefox-Plugin) +

+ + + +

+
    +
    + +
    + + + + + diff --git a/src/SwyxFFPlugin/README.md b/src/SwyxFFPlugin/README.md new file mode 100644 index 0000000..3192eb3 --- /dev/null +++ b/src/SwyxFFPlugin/README.md @@ -0,0 +1,38 @@ +# SwyxFFPlugin + +Firefox-Erweiterung zur Tab-Verwaltung fuer SwyxTray. Das Hintergrundskript +haelt eine WebSocket-Verbindung zum **Plugin-Port** von SwyxTray +(voreingestellt `ws://127.0.0.1:17655/`, einstellbar ueber `PluginPort` in +`%LOCALAPPDATA%\SwyxTray\websocket.json`) und beantwortet drei Auftraege: + +| Auftrag | Antwort | +|---|---| +| `{"type":"tab","id":…,"action":"list"}` | `{"cmd":"tabresult","id":…,"ok":true,"tabs":[{id,title,url,active},…]}` | +| `{"type":"tab","id":…,"action":"open","url":…}` | `{"cmd":"tabresult","id":…,"ok":true,"tabId":…}` | +| `{"type":"tab","id":…,"action":"close","tabId":…}` | `{"cmd":"tabresult","id":…,"ok":true}` | + +Das Protokoll im Einzelnen steht im [Haupt-README](../../README.md) unter +„Firefox-Plugin (SwyxFFPlugin)". + +Auf diesem Kanal fragt SwyxTray, das Plugin antwortet — genutzt vom +`focus`-Kommando des Hauptports und von den Kontextmenuepunkten **Lese Tabs**, +**Tab oeffnen** und **Tab schliessen** des Tray-Symbols. Laeuft SwyxTray (noch) nicht, verbindet das Skript sich selbst +neu: beginnend mit 1 s Abstand, je Fehlversuch verdoppelt bis hoechstens 30 s. + +## Installieren (zum Erproben) + +1. In Firefox `about:debugging#/runtime/this-firefox` oeffnen. +2. **Temporaeres Add-on laden…** und die [manifest.json](manifest.json) dieses + Ordners auswaehlen. +3. Im Protokoll von SwyxTray erscheint `WebSocket … verbunden (…, Plugin-Port)`. + +Ein temporaeres Add-on ueberlebt den Firefox-Neustart nicht. Fuer eine +dauerhafte Installation muss die Erweiterung signiert werden (etwa unsigniert +nur in Firefox Developer Edition/ESR mit `xpinstall.signatures.required=false`). + +Sind in SwyxTray die Handshake-Pruefungen eingeschaltet (zur Zeit sind sie +abgeschaltet, siehe Haupt-README), braucht die Adresse in +[background.js](background.js) zusaetzlich das Token +(`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der Origin +`moz-extension://` der Erweiterung stehen — die UUID zeigt +`about:debugging` als „Interne UUID". diff --git a/src/SwyxFFPlugin/background.js b/src/SwyxFFPlugin/background.js new file mode 100644 index 0000000..06997b7 --- /dev/null +++ b/src/SwyxFFPlugin/background.js @@ -0,0 +1,82 @@ +// SwyxFFPlugin — Hintergrundskript. +// +// Haelt eine WebSocket-Verbindung zum Plugin-Port von SwyxTray (voreingestellt +// 17655, siehe %LOCALAPPDATA%\SwyxTray\websocket.json). Auf diesem Kanal ist +// die Richtung umgekehrt zum Hauptport: SwyxTray fragt, das Plugin antwortet. +// Jeder Auftrag {"type":"tab","id":…,"action":…} wird mit genau einem +// {"cmd":"tabresult","id":…,"ok":…} beantwortet, die id unveraendert. + +const ENDPOINT = "ws://127.0.0.1:17655/"; +const SUBPROTOCOL = "swyxtray.v1"; + +// Wiederverbindung mit Verdopplung bis zur Obergrenze — SwyxTray laeuft +// womoeglich noch nicht, wenn Firefox startet, oder wird zwischendurch beendet. +const RECONNECT_MIN_MS = 1000; +const RECONNECT_MAX_MS = 30000; +let reconnectDelay = RECONNECT_MIN_MS; + +function connect() { + const ws = new WebSocket(ENDPOINT, [SUBPROTOCOL]); + + ws.onopen = () => { + reconnectDelay = RECONNECT_MIN_MS; + console.info(`SwyxFFPlugin: verbunden mit ${ENDPOINT}`); + }; + + ws.onmessage = (e) => handleMessage(ws, e.data); + + // onerror liefert keine Einzelheiten und wird stets von onclose gefolgt — + // die Wiederverbindung haengt deshalb allein an onclose. + ws.onclose = () => { + setTimeout(connect, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); + }; +} + +async function handleMessage(ws, data) { + let m; + try { + m = JSON.parse(data); + } catch { + return; // kaputtes JSON — dazu gibt es keine id, also auch keine Antwort + } + + // hello beim Verbinden und result auf ein ping kommen ohne type "tab" — + // beantwortet wird nur, was eine Auftrags-id traegt. + if (m.type !== "tab") return; + + const reply = (fields) => + ws.send(JSON.stringify({ cmd: "tabresult", id: m.id, ...fields })); + + try { + if (m.action === "list") { + const tabs = await browser.tabs.query({}); + reply({ + ok: true, + tabs: tabs.map((t) => ({ + id: t.id, + title: t.title, + url: t.url, + active: t.active, + })), + }); + } else if (m.action === "open") { + const tab = await browser.tabs.create({ url: m.url }); + reply({ ok: true, tabId: tab.id }); + } else if (m.action === "close") { + if (m.tabId == null) { + reply({ ok: false }); + } else { + await browser.tabs.remove(m.tabId); + reply({ ok: true }); + } + } else { + reply({ ok: false }); + } + } catch (err) { + console.warn(`SwyxFFPlugin: Auftrag '${m.action}' fehlgeschlagen:`, err); + reply({ ok: false }); + } +} + +connect(); diff --git a/src/SwyxFFPlugin/manifest.json b/src/SwyxFFPlugin/manifest.json new file mode 100644 index 0000000..e7fbbc9 --- /dev/null +++ b/src/SwyxFFPlugin/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 2, + "name": "SwyxFFPlugin", + "version": "1.0", + "description": "Tab-Verwaltung fuer SwyxTray: verbindet sich mit ws://127.0.0.1:17655/ und beantwortet die Auftraege list, open und close.", + "permissions": ["tabs"], + "background": { + "scripts": ["background.js"], + "persistent": true + }, + "browser_specific_settings": { + "gecko": { + "id": "swyxffplugin@appcreation.de", + "strict_min_version": "115.0" + } + } +} diff --git a/src/SwyxTray/Log.cs b/src/SwyxTray/Log.cs new file mode 100644 index 0000000..394c719 --- /dev/null +++ b/src/SwyxTray/Log.cs @@ -0,0 +1,67 @@ +using System.Text; + +namespace SwyxTray; + +/// +/// Schlankes Dateiprotokoll unter %LOCALAPPDATA%\SwyxTray\. Bewusst ohne +/// Framework: die App hat kein Fenster, ein Protokoll ist deshalb das einzige +/// Mittel zur Fehlersuche. +/// +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); + } +} diff --git a/src/SwyxTray/NativeMethods.cs b/src/SwyxTray/NativeMethods.cs new file mode 100644 index 0000000..8da745b --- /dev/null +++ b/src/SwyxTray/NativeMethods.cs @@ -0,0 +1,69 @@ +using System.Runtime.InteropServices; + +namespace SwyxTray; + +internal static class NativeMethods +{ + /// + /// Gibt ein von 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. + /// + [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; + + /// Wurde zu spaet aufgerufen — COM war bereits gemarshallt. + 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); + + /// + /// Erlaubt CLMgr, Ereignisse in diesen Prozess zurueckzurufen. + /// + /// CLMgr.exe ist ein eigener Prozess. Beim Anmelden der Ereignissenke + /// (IConnectionPoint::Advise) 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. + /// + /// HRESULT des Aufrufs, 0 = Erfolg. + 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; +} diff --git a/src/SwyxTray/Program.cs b/src/SwyxTray/Program.cs new file mode 100644 index 0000000..db5be69 --- /dev/null +++ b/src/SwyxTray/Program.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; + +namespace SwyxTray; + +internal static class Program +{ + /// + /// Sitzungsweit eindeutig (Local\), nicht maschinenweit: bei + /// Terminalserver-Betrieb soll je Sitzung eine Instanz laufen duerfen, + /// weil jede Sitzung ihren eigenen CLMgr hat. + /// + 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."); + } + } +} diff --git a/src/SwyxTray/Swyx/LineStateExtensions.cs b/src/SwyxTray/Swyx/LineStateExtensions.cs new file mode 100644 index 0000000..bb14e1d --- /dev/null +++ b/src/SwyxTray/Swyx/LineStateExtensions.cs @@ -0,0 +1,87 @@ +using IpPbx.CLMgrLib; + +namespace SwyxTray.Swyx; + +/// +/// Einordnung der aus der CLMgr-TypeLib. +/// Die Werte stammen direkt aus dem Wrapper (Interop.CLMgr), sind also nicht +/// geraten. Die vielen LSWaitingFor*-Werte sind Uebergangszustaende der +/// CLMgr-Zustandsmaschine und werden hier bewusst als "belegt" behandelt. +/// +internal static class LineStateExtensions +{ + /// Eingehender Ruf, der noch nicht angenommen wurde. + 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 + }; + + /// Bestehende Sprechverbindung. + public static bool IsActive(this CLMgrLineStates state) => state switch + { + CLMgrLineStates.LSActive => true, + CLMgrLineStates.LSConferenceActive => true, + CLMgrLineStates.LSDirectCall => true, + CLMgrLineStates.LSTransferring => true, + _ => false + }; + + /// Ausgehender Ruf im Aufbau. + 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 + }; + + /// Leitung frei — wird in der Uebersicht ausgeblendet. + public static bool IsIdle(this CLMgrLineStates state) => state switch + { + CLMgrLineStates.LSInactive => true, + CLMgrLineStates.LSTerminated => true, + CLMgrLineStates.LSDisabled => true, + CLMgrLineStates.LSNone => true, + _ => false + }; + + /// Deutscher Klartext fuer Kontextmenue und Sprechblasen. + 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() + }; +} diff --git a/src/SwyxTray/Swyx/PubCLMgrMessages.cs b/src/SwyxTray/Swyx/PubCLMgrMessages.cs new file mode 100644 index 0000000..61dd418 --- /dev/null +++ b/src/SwyxTray/Swyx/PubCLMgrMessages.cs @@ -0,0 +1,28 @@ +namespace SwyxTray.Swyx; + +/// +/// Ausschnitt aus PubCLMgrMessages (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 msg-Parameters von +/// IClientLineMgrEventsPub.PubOnLineMgrNotification. +/// +/// 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. +/// +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 +} diff --git a/src/SwyxTray/Swyx/SwyxClient.cs b/src/SwyxTray/Swyx/SwyxClient.cs new file mode 100644 index 0000000..5bb94e8 --- /dev/null +++ b/src/SwyxTray/Swyx/SwyxClient.cs @@ -0,0 +1,476 @@ +using System.Runtime.InteropServices; +using System.Text; +using IpPbx.CLMgrLib; + +namespace SwyxTray.Swyx; + +/// +/// Verbindung zum lokal laufenden SwyxIt!-Client ueber den Client Line Manager +/// (CLMgr.exe) mittels der offiziellen Interop-Wrapper aus dem NuGet-Paket +/// Swyx.Client.ClmgrAPI. +/// +/// CLMgr.exe ist ein Out-of-Process-COM-Server. Beim Erzeugen von +/// 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. +/// +internal sealed class SwyxClient : IDisposable +{ + /// Anmeldename gegenueber CLMgr; leer = angemeldeter Benutzer. + 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; + + /// Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat. + public event Action? SnapshotChanged; + + public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline; + + public bool IsConnected => _disp is not null; + + /// + /// Ob der Ereignis-Rueckkanal steht. Ist er es nicht, muss haeufiger + /// abgefragt werden, damit die Anzeige nicht traege wirkt. + /// + public bool EventsActive { get; private set; } + + /// + /// 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. + /// + 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); + } + } + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// 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. + /// + 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(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); + } + + /// + /// Vergleichsschluessel. Records mit Listen vergleichen die Liste per + /// Referenz, deshalb eine eigene, stabile Signatur. + /// + 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. + // --------------------------------------------------------------------- + + /// + /// Waehlt eine Rufnummer auf einer freien Leitung. Liefert die belegte + /// Leitung (1-basiert), sofern sie sich sofort erkennen laesst. + /// + 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; + } + + /// Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert). + 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; + } + + /// Legt auf; liefert die Leitung (1-basiert). + 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; + } + + /// Stellt ein Gespraech in die Warteschleife. + 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!."); + + /// + /// 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. + /// + private int ResolveLine(int? oneBased, Func 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 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); + } + } + + /// + /// 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. + /// + 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."); + } + + /// Verbindung verwerfen und beim naechsten Zyklus neu aufbauen. + 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(); + } +} diff --git a/src/SwyxTray/Swyx/SwyxModels.cs b/src/SwyxTray/Swyx/SwyxModels.cs new file mode 100644 index 0000000..e726610 --- /dev/null +++ b/src/SwyxTray/Swyx/SwyxModels.cs @@ -0,0 +1,118 @@ +using IpPbx.CLMgrLib; + +namespace SwyxTray.Swyx; + +/// +/// Aggregierter Zustand ueber alle Leitungen — das, was das Tray-Icon anzeigt. +/// +public enum SwyxOverallState +{ + /// CLMgr nicht erreichbar (SwyxIt! laeuft nicht). + Offline, + + /// Verbunden, aber der SwyxServer meldet sich nicht. + ServerDown, + + /// Verbunden, keine belegte Leitung. + Idle, + + /// Mindestens eine Leitung klingelt (eingehend). + Ringing, + + /// Ausgehender Ruf im Aufbau. + Dialing, + + /// Mindestens ein Gespraech aktiv. + InCall, + + /// Gespraeche nur noch gehalten. + OnHold +} + +/// Momentaufnahme einer Leitung. +public sealed record SwyxLineInfo( + int Index, + CLMgrLineStates State, + string PeerNumber, + string PeerName, + bool IsSelected) +{ + /// Anzeigename des Gespraechspartners, Nummer als Rueckfallwert. + public string PeerDisplay => + !string.IsNullOrWhiteSpace(PeerName) ? PeerName + : !string.IsNullOrWhiteSpace(PeerNumber) ? PeerNumber + : "unbekannt"; + + /// Name und Nummer, sofern beides bekannt und verschieden. + public string PeerDisplayLong => + !string.IsNullOrWhiteSpace(PeerName) && !string.IsNullOrWhiteSpace(PeerNumber) + ? $"{PeerName} ({PeerNumber})" + : PeerDisplay; + + public bool IsBusy => !State.IsIdle(); +} + +/// Momentaufnahme des gesamten Clients. +public sealed record SwyxSnapshot( + bool IsConnected, + bool IsServerUp, + string UserName, + string ServerName, + IReadOnlyList Lines) +{ + public static SwyxSnapshot Offline { get; } = + new(false, false, string.Empty, string.Empty, Array.Empty()); + + /// Leitungen mit Gespraech bzw. Gespraechsaufbau. + public IEnumerable 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; + } + } + + /// Kurztext fuer QuickInfo und Kontextmenue-Kopf. + 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" + }; +} diff --git a/src/SwyxTray/SwyxTray.csproj b/src/SwyxTray/SwyxTray.csproj new file mode 100644 index 0000000..a4b7f08 --- /dev/null +++ b/src/SwyxTray/SwyxTray.csproj @@ -0,0 +1,33 @@ + + + + + WinExe + net10.0-windows + true + enable + enable + SwyxTray + SwyxTray + app.manifest + + + x64;x86 + x64 + win-x64 + false + true + + + + + + + + diff --git a/src/SwyxTray/TrayApplicationContext.cs b/src/SwyxTray/TrayApplicationContext.cs new file mode 100644 index 0000000..d8e0e9d --- /dev/null +++ b/src/SwyxTray/TrayApplicationContext.cs @@ -0,0 +1,506 @@ +using System.Diagnostics; +using SwyxTray.Swyx; +using SwyxTray.Web; + +namespace SwyxTray; + +/// +/// Der eigentliche Anwendungsrumpf: kein Fenster, nur ein NotifyIcon im +/// Infobereich der Taskleiste samt Kontextmenue. +/// +internal sealed class TrayApplicationContext : ApplicationContext +{ + /// + /// 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. + /// + private static readonly TimeSpan SafetyInterval = TimeSpan.FromSeconds(5); + + /// + /// Kuerzeres Intervall, wenn der Ereignis-Rueckkanal nicht steht — dann ist + /// die Abfrage die einzige Quelle fuer Zustandsaenderungen. + /// + 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; + + /// Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen". + 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 }; + } + + /// + /// 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. + /// + private async void RunTabAction(ToolStripMenuItem item, Func action) + { + if (_server is not { IsRunning: true } server) + { + return; + } + + item.Enabled = false; + try + { + await action(server); + } + finally + { + if (!_disposed) + { + item.Enabled = true; + } + } + } + + /// + /// 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. + /// + 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); + } + + /// Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite. + 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); + } + + /// + /// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando + /// als Komposition aus den Grundauftraegen des Kanals: erst list, + /// dann je Treffer ein close mit der Tab-Id. So laesst sich auch + /// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt. + /// + 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); + } + + /// + /// Legt Adresse samt Token in die Zwischenablage — beim Einrichten einer + /// Seite ist das der einzige Wert, den jemand von Hand uebertragen muss. + /// + 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); + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// Sprechblase nur fuer Leitungen, die neu zu klingeln beginnen — nicht bei + /// jedem Zustandswechsel, sonst wird der Benutzer waehrend eines Gespraechs + /// mehrfach benachrichtigt. + /// + 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)] + "…"; +} diff --git a/src/SwyxTray/TrayIcons.cs b/src/SwyxTray/TrayIcons.cs new file mode 100644 index 0000000..477b965 --- /dev/null +++ b/src/SwyxTray/TrayIcons.cs @@ -0,0 +1,90 @@ +using System.Drawing.Drawing2D; +using SwyxTray.Swyx; + +namespace SwyxTray; + +/// +/// Erzeugt die Symbole zur Laufzeit (Kreis mit Hoererbogen), damit keine +/// .ico-Dateien mitgeliefert werden muessen. Wer eigene Symbole verwenden +/// will, ersetzt einfach durch das Laden aus Ressourcen. +/// +/// Die Symbole werden einmal je Zustand erzeugt und zwischengespeichert — +/// GDI-Handles sind eine begrenzte Ressource. +/// +internal static class TrayIcons +{ + private static readonly Dictionary Cache = new(); + + private static readonly Dictionary 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(); + } +} diff --git a/src/SwyxTray/Web/CommandExecutor.cs b/src/SwyxTray/Web/CommandExecutor.cs new file mode 100644 index 0000000..32775ec --- /dev/null +++ b/src/SwyxTray/Web/CommandExecutor.cs @@ -0,0 +1,196 @@ +using System.Runtime.InteropServices; +using SwyxTray.Swyx; + +namespace SwyxTray.Web; + +/// +/// Setzt die Kommandos der Webseite in Aufrufe des um +/// — jeden davon im UI-Thread, weil dort die COM-Verbindung lebt. +/// +internal sealed class CommandExecutor +{ + /// + /// Obergrenze fuer einen Sprung in den UI-Thread. Ohne sie wuerde eine + /// blockierte Nachrichtenschleife (etwa ein haengender COM-Aufruf) die + /// Verbindung stillschweigend einfrieren. + /// + 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 GetSnapshotAsync(CancellationToken cancellationToken) + => RunAsync(() => _client.Current, cancellationToken); + + public async Task 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(() => { _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."); + } + } + + /// + /// Aktiviert den zur Anfrage passenden Firefox-Tab ueber das Plugin + /// (true = der Tab war schon offen) oder oeffnet die Adresse als + /// neuen Tab (false). Ohne verbundenes Plugin scheitert das + /// Kommando — einen anderen Weg, eine Seite anzuzeigen, gibt es nicht. + /// + private async Task 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 + } + + /// Die offenen Tabs — die drei Tab-Kommandos des Hauptports + /// reichen die Auftraege unveraendert an das Firefox-Plugin durch. + private async Task> ListTabsAsync(CancellationToken cancellationToken) + => await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + "Kein Firefox-Plugin verbunden oder es antwortet nicht."); + + private async Task 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."); + } + } + + /// + /// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz + /// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen. + /// + 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 RunAsync(Func function, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(CommandTimeout); + return await _ui.RunAsync(function, timeout.Token).ConfigureAwait(false); + } +} diff --git a/src/SwyxTray/Web/LocalWebSocketServer.cs b/src/SwyxTray/Web/LocalWebSocketServer.cs new file mode 100644 index 0000000..9cdb67d --- /dev/null +++ b/src/SwyxTray/Web/LocalWebSocketServer.cs @@ -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; + +/// +/// 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 +/// 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. +/// +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 _listeners = new(); + private readonly ConcurrentDictionary _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; + + /// Zweiter Port fuer das Firefox-Plugin; 0 = abgeschaltet. + public int PluginPort => _config.PluginPort; + + /// + /// 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. + /// + public string Endpoint => $"ws://{_host}:{_config.Port}/?token={_config.Token}"; + + public bool IsRemote => _config.AllowRemoteAccess; + + /// + /// Fragt die offenen Tabs beim Firefox-Plugin ab. null, wenn kein + /// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt. + /// + public Task?> TryListTabsAsync(CancellationToken cancellationToken) + => _tabChannel.TryListTabsAsync(cancellationToken); + + /// + /// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL. + /// null bei fehlendem Plugin, Zeitablauf oder Ablehnung. + /// + public Task TryOpenTabAsync(string url, CancellationToken cancellationToken) + => _tabChannel.TryOpenTabAsync(url, cancellationToken); + + /// + /// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id. + /// true nur bei bestaetigtem Erfolg. + /// + public Task 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})."); + } + } + + /// + /// Die IPv4-Adressen dieses Rechners — nur fuer die Anzeige, damit im + /// Protokoll und in der Zwischenablage steht, was ein zweiter Rechner + /// tatsaechlich ansprechen kann. + /// + 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"]; + } + } + + /// + /// Nur der IPv4-Listener des Hauptports ist Bedingung (); + /// alles Weitere — IPv6 und der Plugin-Port — ist Zugabe: faellt es aus, + /// laeuft der Zugang ueber die verbleibenden Listener weiter. + /// + 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); + } + + /// Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten. + public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot); + + /// + /// 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. + /// + public void Broadcast(SwyxSnapshot previous, SwyxSnapshot current) + { + if (_sessions.IsEmpty) + { + return; + } + + var messages = new List { 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; + } +} diff --git a/src/SwyxTray/Web/PluginTabChannel.cs b/src/SwyxTray/Web/PluginTabChannel.cs new file mode 100644 index 0000000..beec65d --- /dev/null +++ b/src/SwyxTray/Web/PluginTabChannel.cs @@ -0,0 +1,98 @@ +using System.Collections.Concurrent; + +namespace SwyxTray.Web; + +/// +/// Der Tab-Verwaltungskanal zum Firefox-Plugin: SwyxTray schickt Auftraege +/// (), das Plugin antwortet mit +/// tabresult. 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. +/// +internal sealed class PluginTabChannel +{ + /// + /// So lange darf das Plugin brauchen. Danach faellt focus auf den + /// Browserstart zurueck, statt die Kommando-Antwort aufzuhalten. + /// + private static readonly TimeSpan AnswerTimeout = TimeSpan.FromSeconds(5); + + private readonly ConcurrentDictionary _plugins = new(); + private readonly ConcurrentDictionary> _pending = new(); + private int _nextRequestId; + + public void Register(WebSocketSession session) => _plugins[session.Id] = session; + + public void Unregister(WebSocketSession session) => _plugins.TryRemove(session.Id, out _); + + /// + /// Die offenen Tabs, oder null, wenn kein Plugin verbunden ist, + /// keine Antwort kommt oder das Plugin den Auftrag ablehnt. + /// + public async Task?> TryListTabsAsync(CancellationToken cancellationToken) + => await RequestAsync(id => new TabRequestMessage(id, "list"), cancellationToken) + .ConfigureAwait(false) is { Ok: true } answer + ? answer.Tabs ?? [] + : null; + + /// + /// Oeffnet einen neuen Tab mit der URL. Liefert die Id des neuen Tabs, + /// oder null bei fehlendem Plugin, Zeitablauf oder Ablehnung. + /// + public async Task 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; + + /// Schliesst den Tab. true nur bei bestaetigtem Erfolg. + public async Task TryCloseTabAsync(int tabId, CancellationToken cancellationToken) + => (await RequestAsync(id => new TabRequestMessage(id, "close", TabId: tabId), cancellationToken) + .ConfigureAwait(false))?.Ok == true; + + private async Task RequestAsync( + Func 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( + 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 _); + } + } + + /// Ordnet ein tabresult des Plugins der wartenden Anfrage zu. + public bool TryComplete(ClientCommand tabResult) + => tabResult.Id is { } id + && _pending.TryRemove(id, out var pending) + && pending.TrySetResult(tabResult); +} diff --git a/src/SwyxTray/Web/Protocol.cs b/src/SwyxTray/Web/Protocol.cs new file mode 100644 index 0000000..7757319 --- /dev/null +++ b/src/SwyxTray/Web/Protocol.cs @@ -0,0 +1,209 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SwyxTray.Swyx; + +namespace SwyxTray.Web; + +/// +/// 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. +/// +internal static class Protocol +{ + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static string Serialize(T message) => JsonSerializer.Serialize(message, Json); +} + +/// Nachricht der Webseite an die App. +internal sealed class ClientCommand +{ + /// Frei waehlbar; wird in der Antwort zurueckgegeben. + public int? Id { get; set; } + + public string? Cmd { get; set; } + + /// Nur bei call. + public string? Number { get; set; } + + /// 1-basierte Leitung; fehlt sie, waehlt die App selbst. + public int? Line { get; set; } + + /// Nur bei focus: gesuchter Fenstertitel. + public string? Title { get; set; } + + /// + /// Bei focus und opentab: die zu oeffnende Adresse. + /// + public string? Url { get; set; } + + /// Nur bei tabresult (Plugin-Port): Auftrag ausgefuehrt? + public bool? Ok { get; set; } + + /// + /// Bei closetab (Hauptport): der zu schliessende Tab. Bei + /// tabresult auf open (Plugin-Port): Id des neuen Tabs. + /// + public int? TabId { get; set; } + + /// Nur bei tabresult auf list: die offenen Tabs. + public List? Tabs { get; set; } +} + +/// Ein offener Tab, wie ihn das Plugin bei list meldet. +internal sealed class TabInfo +{ + public int? Id { get; set; } + public string? Title { get; set; } + public string? Url { get; set; } + public bool? Active { get; set; } +} + +/// Begruessung unmittelbar nach dem Verbindungsaufbau. +internal sealed record HelloMessage(string App, string Version, int Protocol, int Session) +{ + public string Type => "hello"; +} + +/// +/// Antwort auf genau ein Kommando. Focused wird nur bei focus +/// gesetzt: true = Fenster in den Vordergrund geholt, false = +/// stattdessen den Browser mit der URL gestartet. Tabs steht nur in +/// der Antwort auf tabs, TabId nur in der auf opentab. +/// +internal sealed record ResultMessage( + int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null, + int? TabId = null, IReadOnlyList? Tabs = null) +{ + public string Type => "result"; +} + +/// +/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen: +/// list (offene Tabs melden), open (neuen Tab mit Url +/// oeffnen), close (Tab TabId schliessen). Das Plugin antwortet +/// jeweils mit {"cmd":"tabresult","id":…,"ok":…} — bei list +/// zusaetzlich mit tabs, bei open mit tabId. Die Id +/// stammt hier vom Server, nicht von einer Webseite. +/// +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); + +/// +/// 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. +/// +/// Event ist incoming (Leitung beginnt zu klingeln), +/// outgoing (Wahl beginnt), connected (Gespraech steht) oder +/// ended (Leitung wieder frei). Bei connected steht in +/// Direction, ob der Ruf ein- oder ausgehend war, sofern erkennbar; +/// bei ended stammen die Peer-Angaben aus dem letzten belegten Zustand. +/// +internal sealed record CallEventMessage( + string Event, + int Line, + string Peer, + string PeerNumber, + string PeerName, + string? Direction = null) +{ + public string Type => "call"; + + public static IReadOnlyList Diff(SwyxSnapshot previous, SwyxSnapshot current) + { + var events = new List(); + 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); +} + +/// +/// Vollstaendiger Zustand. Wird nach dem hello, bei jeder Aenderung und auf +/// status gesendet — die Webseite muss also nie einen Zustand mitfuehren. +/// +internal sealed record SnapshotMessage( + bool Connected, + bool ServerUp, + string Overall, + string StatusText, + string User, + string Server, + IReadOnlyList 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()); +} diff --git a/src/SwyxTray/Web/UiDispatcher.cs b/src/SwyxTray/Web/UiDispatcher.cs new file mode 100644 index 0000000..ac49822 --- /dev/null +++ b/src/SwyxTray/Web/UiDispatcher.cs @@ -0,0 +1,48 @@ +namespace SwyxTray.Web; + +/// +/// 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. +/// +internal sealed class UiDispatcher +{ + private readonly SynchronizationContext _context; + + private UiDispatcher(SynchronizationContext context) => _context = context; + + /// Liefert null, wenn kein UI-Kontext installiert ist. + public static UiDispatcher? Capture() + => SynchronizationContext.Current is { } context ? new UiDispatcher(context) : null; + + public async Task RunAsync(Func function, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(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); + } +} diff --git a/src/SwyxTray/Web/WebSocketConfig.cs b/src/SwyxTray/Web/WebSocketConfig.cs new file mode 100644 index 0000000..d75ece1 --- /dev/null +++ b/src/SwyxTray/Web/WebSocketConfig.cs @@ -0,0 +1,218 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SwyxTray.Web; + +/// +/// 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. +/// +internal sealed class WebSocketConfig +{ + /// + /// Version des JSON-Protokolls, wird im hello-Paket gemeldet. + /// Version 2: zusaetzliche call-Ereignisse (incoming/outgoing/ + /// connected/ended) nach jedem snapshot. + /// Version 3: Kommando focus (Fenster in den Vordergrund holen, + /// ersatzweise URL im Standardbrowser oeffnen). + /// Version 4: Plugin-Port als reiner Tab-Verwaltungskanal + /// (tab-Auftraege an das Firefox-Plugin, tabresult zurueck). + /// Version 5: der eine Tab-Auftrag ist in die drei Aktionen list, + /// open und close aufgeteilt. + /// Version 6: die Tab-Verwaltung steht als tabs, opentab + /// und closetab auch Webseiten auf dem Hauptport offen. + /// + public const int ProtocolVersion = 6; + + /// + /// 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 + /// 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 + /// , es ist also nur diese eine + /// Zeile. + /// + /// Kein const, sonst meldet der Compiler die abhaengigen Zweige als + /// unerreichbar. + /// + 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; + + /// + /// 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. + /// + public int PluginPort { get; set; } = 17655; + + /// Gemeinsames Geheimnis. Leer = Zugang gesperrt. + public string Token { get; set; } = string.Empty; + + /// + /// Erlaubte Werte des Origin-Headers, z. B. "https://crm.example.local". + /// Ein Eintrag "*" laesst jede Seite zu und wird beim Start bemaengelt. + /// + public List AllowedOrigins { get; set; } = new(); + + public int MaxConnections { get; set; } = 8; + + /// + /// Voreingestellt hoert die App nur auf 127.0.0.1 und ::1. Auf true + /// 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. + /// + public bool AllowRemoteAccess { get; set; } + + /// + /// Laedt die Konfiguration; legt sie beim ersten Start an. Bei einer + /// defekten Datei wird bewusst null geliefert und der Server nicht + /// gestartet: ein neu erzeugtes Token wuerde alle Clients aussperren, ohne + /// dass jemand die Ursache sieht. + /// + 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(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; + } + + /// + /// 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. + /// + public bool IsOriginAllowed(string origin) + => AllowedOrigins.Any(allowed => + { + var trimmed = allowed.Trim(); + return trimmed == "*" + || string.Equals(trimmed.TrimEnd('/'), origin.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + }); + + /// Zeitkonstanter Vergleich — das Token ist ein Geheimnis. + 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)); +} diff --git a/src/SwyxTray/Web/WebSocketHandshake.cs b/src/SwyxTray/Web/WebSocketHandshake.cs new file mode 100644 index 0000000..cd0fb89 --- /dev/null +++ b/src/SwyxTray/Web/WebSocketHandshake.cs @@ -0,0 +1,261 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SwyxTray.Web; + +/// +/// 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 +/// . +/// +internal static class WebSocketHandshake +{ + /// Feste GUID aus RFC 6455 zur Bildung des Accept-Werts. + private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + private const int MaxHeaderBytes = 16 * 1024; + + public const string SubProtocol = "swyxtray.v1"; + + /// Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding. + private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"]; + + internal sealed record Request( + string Method, + string Target, + IReadOnlyDictionary Headers, + string? Token, + string Origin, + string? SubProtocol); + + /// + /// 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. + /// + public static async Task ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var buffer = new byte[1]; + var raw = new List(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(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); + } + + /// + /// Das Token darf in der Abfragezeichenfolge (?token=…) oder als + /// Unterprotokoll token.… 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. + /// + private static string? TokenFrom(string target, IEnumerable 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; + } + + /// + /// Prueft die Anfrage. Liefert null, wenn alles passt, sonst + /// HTTP-Status und Begruendung fuer die Absage. + /// + 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); + } + + /// + /// 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. + /// + 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" + }; +} diff --git a/src/SwyxTray/Web/WebSocketSession.cs b/src/SwyxTray/Web/WebSocketSession.cs new file mode 100644 index 0000000..955273a --- /dev/null +++ b/src/SwyxTray/Web/WebSocketSession.cs @@ -0,0 +1,247 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace SwyxTray.Web; + +/// +/// 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. +/// +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; } + + /// + /// Verbindung ueber den Plugin-Port: darauf gibt es nur die + /// Tab-Verwaltung — keine Zustandsmeldungen, keine Telefonie-Kommandos. + /// + 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(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); + } + + /// + /// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf + /// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich + /// ueber den Hauptport. + /// + 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); + } + + /// + /// 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. + /// + public Task SendAsync(string json, CancellationToken cancellationToken) + => SendManyAsync([json], cancellationToken); + + /// + /// 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. + /// + public async Task SendManyAsync(IReadOnlyList 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(); + } + } + + /// Fuer das Verteilen an alle: ein toter Client darf nicht stoeren. + public async Task TrySendManyAsync(IReadOnlyList 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(); + } +} diff --git a/src/SwyxTray/app.manifest b/src/SwyxTray/app.manifest new file mode 100644 index 0000000..fce0450 --- /dev/null +++ b/src/SwyxTray/app.manifest @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2 + true + + + +