Compare commits

...
2 Commits
Author SHA1 Message Date
Sven cd355c4e21 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.gitignore
#	.vscode/launch.json
2026-08-21 11:34:32 +02:00
Sven 78b17a1350 SwyxTray und Firefox-Plugin in das Repository aufnehmen
Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang,
Firefox-Erweiterung und Browser-Beispielclient.
2026-08-21 11:33:52 +02:00
28 changed files with 4409 additions and 55 deletions
+5
View File
@@ -36,3 +36,8 @@ yarn-error.log*
tsconfig.tsbuildinfo
core/vite.generated.ts
core/src/main/frontend/generated/
# --- .NET (SwyxTray) ---
bin/
obj/
*.user
+37
View File
@@ -1,4 +1,8 @@
{
// Startkonfigurationen aller Teilprojekte: Java-Core, Flutter-App und
// SwyxTray. Die SwyxTray-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": [
{
@@ -52,6 +56,39 @@
"program": "${workspaceFolder}/app/lib/main.dart",
"cwd": "${workspaceFolder}/app",
"flutterMode": "release"
},
{
// 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"
}
]
}
+67
View File
@@ -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
+77
View File
@@ -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": []
}
]
}
+603
View File
@@ -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
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
```
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://<UUID>`,
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
`<TargetFramework>net8.0-windows</TargetFramework>` 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)
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>SwyxTray — WebSocket-Beispiel</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
fieldset { border: 1px solid #ccc; border-radius: 6px; margin-bottom: 1rem; }
input, button { font: inherit; padding: .35rem .6rem; }
input[type=text] { width: 22rem; }
#zustand { font-weight: 600; }
#protokoll { background: #f6f6f6; padding: .75rem; height: 16rem; overflow: auto;
white-space: pre-wrap; font: 13px/1.45 ui-monospace, monospace; }
</style>
</head>
<body>
<h1>SwyxTray — WebSocket-Beispiel</h1>
<p>
<strong>Zur Zeit sind alle Prüfungen abgeschaltet</strong> — es genügt
<code>ws://127.0.0.1:17654/</code> ohne Token, auch beim Öffnen dieser Seite
direkt aus dem Dateisystem.
</p>
<p>
Von einem <strong>anderen Rechner</strong> aus: in
<code>websocket.json</code> <code>"AllowRemoteAccess": true</code> setzen, App
neu starten und die LAN-Adresse verwenden
(<code>ws://192.168.x.y:17654/…</code>). Diese Seite muss dann über
<code>http://</code> ausgeliefert werden — aus einer HTTPS-Seite heraus
blockiert der Browser <code>ws://</code> auf eine entfernte Adresse als Mixed
Content. Für Loopback gilt diese Ausnahme, für die LAN-Adresse nicht.
</p>
<p>
Sind die Prüfungen wieder eingeschaltet, muss die Seite von einem Origin
geladen werden, der in <code>%LOCALAPPDATA%\SwyxTray\websocket.json</code>
unter <code>AllowedOrigins</code> steht, und die Adresse braucht das Token.
Über <code>file://</code> sendet der Browser den Origin <code>null</code>
dann muss dort <code>"null"</code> eingetragen sein.
</p>
<fieldset>
<legend>Verbindung</legend>
<input type="text" id="url" placeholder="ws://127.0.0.1:17654/?token=…">
<button id="verbinden">Verbinden</button>
<p>Status: <span id="zustand">nicht verbunden</span></p>
</fieldset>
<fieldset>
<legend>Steuerung</legend>
<input type="text" id="nummer" placeholder="+49 30 1234567">
<button data-cmd="call">Anrufen</button>
<button data-cmd="answer">Annehmen</button>
<button data-cmd="hangup">Auflegen</button>
<button data-cmd="hold">Halten</button>
<button data-cmd="status">Status</button>
<p>
<input type="text" id="fenstertitel" placeholder="Tab-Titel (optional)">
<input type="text" id="fokusurl" placeholder="https://… (Pflicht)">
<button data-cmd="focus">Fokussieren</button>
</p>
</fieldset>
<fieldset>
<legend>Tabs (Firefox-Plugin)</legend>
<p>
<button data-cmd="tabs">Tabs anzeigen</button>
<input type="text" id="taburl" placeholder="https://…">
<button data-cmd="opentab">Tab öffnen</button>
</p>
<ul id="tabliste"></ul>
</fieldset>
<div id="protokoll"></div>
<script>
let ws = null;
let naechsteId = 1;
let absichtlichGetrennt = false;
const gesendet = new Map(); // id → cmd, um Antworten dem Kommando zuzuordnen
const $ = id => document.getElementById(id);
const log = text => {
$("protokoll").textContent += text + "\n";
$("protokoll").scrollTop = $("protokoll").scrollHeight;
};
function verbinden() {
absichtlichGetrennt = false;
ws = new WebSocket($("url").value.trim(), ["swyxtray.v1"]);
ws.onopen = () => { $("zustand").textContent = "verbunden"; log("→ verbunden"); };
ws.onmessage = e => {
const m = JSON.parse(e.data);
log("← " + e.data);
// Der Zustand kommt unaufgefordert bei jeder Änderung — die Seite muss
// ihn also nie selbst nachhalten.
if (m.type === "snapshot") {
const belegt = m.lines.filter(l => l.busy);
$("zustand").textContent = m.statusText +
(belegt.length ? " — " + belegt.map(l => `L${l.line}: ${l.stateText} ${l.peer}`).join(", ") : "");
}
// Anruf-Ereignisse kommen zusätzlich zum snapshot — hier reagiert eine
// echte Anwendung, z. B. mit dem Öffnen der Kundenakte zum Anrufer.
if (m.type === "call") {
const text = { incoming: "Eingehender Ruf", outgoing: "Ausgehender Ruf",
connected: "Gespräch verbunden", ended: "Gespräch beendet" }[m.event] ?? m.event;
log(`${text} — Leitung ${m.line}: ${m.peer}`);
}
if (m.type === "result") {
const cmd = gesendet.get(m.id);
gesendet.delete(m.id);
if (cmd === "tabs" && m.ok) zeigeTabs(m.tabs ?? []);
// Nach Öffnen oder Schließen die Liste auffrischen, sofern sie schon
// einmal angezeigt wurde — der Zustand kommt hier nicht von selbst.
if ((cmd === "opentab" || cmd === "closetab") && m.ok
&& $("tabliste").childElementCount > 0) senden("tabs");
}
};
ws.onerror = () => log("→ Fehler (Token? Origin? Läuft SwyxTray?)");
// WebSockets verbinden sich nicht von selbst neu — anders als EventSource.
ws.onclose = e => {
$("zustand").textContent = "getrennt";
log(`→ getrennt (Code ${e.code})`);
if (!absichtlichGetrennt) setTimeout(verbinden, 2000);
};
}
function senden(cmd, felder = {}) {
if (ws?.readyState !== WebSocket.OPEN) { log("→ nicht verbunden"); return; }
const nachricht = { id: naechsteId++, cmd, ...felder };
if (cmd === "call") nachricht.number = $("nummer").value.trim();
if (cmd === "focus") {
nachricht.title = $("fenstertitel").value.trim();
const url = $("fokusurl").value.trim();
if (url) nachricht.url = url;
}
if (cmd === "opentab") {
const url = $("taburl").value.trim();
if (url) nachricht.url = url;
}
gesendet.set(nachricht.id, cmd);
log("→ " + JSON.stringify(nachricht));
ws.send(JSON.stringify(nachricht));
}
// Baut die Liste per createElement auf — Titel und Adressen stammen aus dem
// Browser und gehören nicht ungeprüft in innerHTML.
function zeigeTabs(tabs) {
const liste = $("tabliste");
liste.replaceChildren();
for (const tab of tabs) {
const li = document.createElement("li");
const knopf = document.createElement("button");
knopf.textContent = "Schließen";
knopf.onclick = () => senden("closetab", { tabId: tab.id });
li.append(knopf, ` ${tab.active ? "▶ " : ""}${tab.title ?? ""}${tab.url ?? ""}`);
liste.append(li);
}
if (!tabs.length) {
const li = document.createElement("li");
li.textContent = "(keine Tabs)";
liste.append(li);
}
}
$("verbinden").onclick = () => {
absichtlichGetrennt = true;
ws?.close();
verbinden();
};
document.querySelectorAll("button[data-cmd]").forEach(
b => b.onclick = () => senden(b.dataset.cmd));
</script>
</body>
</html>
+38
View File
@@ -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://<UUID>` der Erweiterung stehen — die UUID zeigt
`about:debugging` als „Interne UUID".
+82
View File
@@ -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();
+17
View File
@@ -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"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Text;
namespace SwyxTray;
/// <summary>
/// Schlankes Dateiprotokoll unter %LOCALAPPDATA%\SwyxTray\. Bewusst ohne
/// Framework: die App hat kein Fenster, ein Protokoll ist deshalb das einzige
/// Mittel zur Fehlersuche.
/// </summary>
internal static class Log
{
private const long MaxBytes = 1024 * 1024;
private static readonly object Gate = new();
public static bool VerboseEnabled { get; set; }
public static string Directory { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SwyxTray");
public static string FilePath { get; } = Path.Combine(Directory, "swyxtray.log");
public static void Debug(string message)
{
if (VerboseEnabled)
{
Write("DBG", message);
}
}
public static void Info(string message) => Write("INF", message);
public static void Error(string message, Exception? ex = null)
=> Write("ERR", ex is null ? message : $"{message} :: {ex}");
private static void Write(string level, string message)
{
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
lock (Gate)
{
try
{
System.IO.Directory.CreateDirectory(Directory);
RollIfTooLarge();
File.AppendAllText(FilePath, line, Encoding.UTF8);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Ein fehlschlagendes Protokoll darf die App nicht beenden.
}
}
}
private static void RollIfTooLarge()
{
var info = new FileInfo(FilePath);
if (!info.Exists || info.Length < MaxBytes)
{
return;
}
var backup = FilePath + ".1";
File.Delete(backup);
File.Move(FilePath, backup);
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Runtime.InteropServices;
namespace SwyxTray;
internal static class NativeMethods
{
/// <summary>
/// Gibt ein von <see cref="System.Drawing.Bitmap.GetHicon"/> erzeugtes
/// HICON frei. Ohne diesen Aufruf leckt jedes erzeugte Symbol ein
/// GDI-Handle.
///
/// Bewusst DllImport statt LibraryImport: Letzteres verlangt
/// AllowUnsafeBlocks fuer das gesamte Projekt, was fuer diesen einen
/// Aufruf unverhaeltnismaessig waere.
/// </summary>
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool DestroyIcon(IntPtr hIcon);
private const uint RpcCAuthnLevelNone = 1;
private const uint RpcCImpLevelIdentify = 2;
private const uint EoacNone = 0;
/// <summary>Wurde zu spaet aufgerufen — COM war bereits gemarshallt.</summary>
private const int RpcETooLate = unchecked((int)0x80010119);
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
[DllImport("ole32.dll")]
private static extern int CoInitializeSecurity(
IntPtr pSecDesc, int cAuthSvc, IntPtr asAuthSvc, IntPtr pReserved1,
uint dwAuthnLevel, uint dwImpLevel, IntPtr pAuthList,
uint dwCapabilities, IntPtr pReserved3);
/// <summary>
/// Erlaubt CLMgr, Ereignisse in diesen Prozess zurueckzurufen.
///
/// CLMgr.exe ist ein eigener Prozess. Beim Anmelden der Ereignissenke
/// (<c>IConnectionPoint::Advise</c>) ruft CLMgr in unseren Prozess zurueck.
/// Ohne eigene Sicherheitsvorgabe verwendet COM Standardwerte, unter denen
/// dieser Rueckruf mit E_ACCESSDENIED abgewiesen wird.
///
/// Authentifizierungsstufe NONE bedeutet: eingehende COM-Aufrufe an diesen
/// Prozess werden nicht authentifiziert. Das ist die uebliche Einstellung
/// fuer COM-Ereignissenken und betrifft nur diesen Prozess, der ausser der
/// Senke keine Schnittstellen nach aussen anbietet. Wer es enger fassen
/// will, probiert RPC_C_AUTHN_LEVEL_CONNECT (2) — das genuegt, solange
/// CLMgr in derselben Sitzung und unter demselben Konto laeuft.
///
/// Muss vor dem ersten COM-Aufruf erfolgen; danach liefert Windows
/// RPC_E_TOO_LATE.
/// </summary>
/// <returns>HRESULT des Aufrufs, 0 = Erfolg.</returns>
internal static int InitializeComSecurity()
{
// Das Apartment steht durch [STAThread] fest; dieser Aufruf stellt nur
// sicher, dass COM initialisiert ist, bevor die Sicherheit gesetzt wird.
const uint coinitApartmentThreaded = 0x2;
CoInitializeEx(IntPtr.Zero, coinitApartmentThreaded);
return CoInitializeSecurity(
IntPtr.Zero, -1, IntPtr.Zero, IntPtr.Zero,
RpcCAuthnLevelNone, RpcCImpLevelIdentify, IntPtr.Zero,
EoacNone, IntPtr.Zero);
}
internal static bool IsTooLate(int hresult) => hresult == RpcETooLate;
}
+64
View File
@@ -0,0 +1,64 @@
using System.Diagnostics;
namespace SwyxTray;
internal static class Program
{
/// <summary>
/// Sitzungsweit eindeutig (Local\), nicht maschinenweit: bei
/// Terminalserver-Betrieb soll je Sitzung eine Instanz laufen duerfen,
/// weil jede Sitzung ihren eigenen CLMgr hat.
/// </summary>
private const string InstanceMutexName = @"Local\SwyxTray.SingleInstance";
[STAThread]
private static void Main(string[] args)
{
Log.VerboseEnabled = args.Any(a =>
a.Equals("--verbose", StringComparison.OrdinalIgnoreCase) ||
a.Equals("-v", StringComparison.OrdinalIgnoreCase));
using var mutex = new Mutex(initiallyOwned: true, InstanceMutexName, out var isFirstInstance);
if (!isFirstInstance)
{
Log.Info("Eine Instanz laeuft bereits in dieser Sitzung — Start abgebrochen.");
return;
}
// Muss vor dem ersten COM-Aufruf stehen, sonst kann CLMgr keine
// Ereignisse in diesen Prozess zurueckrufen (E_ACCESSDENIED bei Advise).
var securityResult = NativeMethods.InitializeComSecurity();
ApplicationConfiguration.Initialize();
// Die App hat kein Fenster. Ein unbehandelter Fehler wuerde sie sonst
// kommentarlos beenden, deshalb wird alles protokolliert.
Application.ThreadException += (_, e) =>
Log.Error("Unbehandelter Fehler im UI-Thread.", e.Exception);
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
Log.Error("Unbehandelter Fehler.", e.ExceptionObject as Exception);
Log.Info($"SwyxTray gestartet (PID {Environment.ProcessId}, " +
$"Sitzung {Process.GetCurrentProcess().SessionId}, " +
$"{(Environment.Is64BitProcess ? "x64" : "x86")}).");
if (securityResult != 0)
{
Log.Info($"CoInitializeSecurity lieferte 0x{securityResult:X8}" +
(NativeMethods.IsTooLate(securityResult)
? " (RPC_E_TOO_LATE — COM war bereits initialisiert)."
: "."));
}
try
{
using var context = new TrayApplicationContext();
Application.Run(context);
}
finally
{
TrayIcons.DisposeAll();
Log.Info("SwyxTray beendet.");
}
}
}
+87
View File
@@ -0,0 +1,87 @@
using IpPbx.CLMgrLib;
namespace SwyxTray.Swyx;
/// <summary>
/// Einordnung der <see cref="CLMgrLineStates"/> aus der CLMgr-TypeLib.
/// Die Werte stammen direkt aus dem Wrapper (Interop.CLMgr), sind also nicht
/// geraten. Die vielen <c>LSWaitingFor*</c>-Werte sind Uebergangszustaende der
/// CLMgr-Zustandsmaschine und werden hier bewusst als "belegt" behandelt.
/// </summary>
internal static class LineStateExtensions
{
/// <summary>Eingehender Ruf, der noch nicht angenommen wurde.</summary>
public static bool IsRinging(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSRinging => true,
CLMgrLineStates.LSKnocking => true, // zweiter Ruf waehrend eines Gespraechs
CLMgrLineStates.LSActiveAlerting => true,
CLMgrLineStates.LSActiveAlertingDC => true,
_ => false
};
/// <summary>Bestehende Sprechverbindung.</summary>
public static bool IsActive(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSActive => true,
CLMgrLineStates.LSConferenceActive => true,
CLMgrLineStates.LSDirectCall => true,
CLMgrLineStates.LSTransferring => true,
_ => false
};
/// <summary>Ausgehender Ruf im Aufbau.</summary>
public static bool IsDialing(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSDialing => true,
CLMgrLineStates.LSAlerting => true,
CLMgrLineStates.LSHookOffInternal => true,
CLMgrLineStates.LSHookOffExternal => true,
CLMgrLineStates.LSBusy => true,
_ => false
};
public static bool IsOnHold(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSOnHold => true,
CLMgrLineStates.LSConferenceOnHold => true,
_ => false
};
/// <summary>Leitung frei — wird in der Uebersicht ausgeblendet.</summary>
public static bool IsIdle(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSInactive => true,
CLMgrLineStates.LSTerminated => true,
CLMgrLineStates.LSDisabled => true,
CLMgrLineStates.LSNone => true,
_ => false
};
/// <summary>Deutscher Klartext fuer Kontextmenue und Sprechblasen.</summary>
public static string ToDisplayText(this CLMgrLineStates state) => state switch
{
CLMgrLineStates.LSInactive => "frei",
CLMgrLineStates.LSHookOffInternal => "abgehoben (intern)",
CLMgrLineStates.LSHookOffExternal => "abgehoben (extern)",
CLMgrLineStates.LSRinging => "klingelt",
CLMgrLineStates.LSDialing => "waehlt",
CLMgrLineStates.LSAlerting => "ruft an",
CLMgrLineStates.LSKnocking => "klopft an",
CLMgrLineStates.LSBusy => "besetzt",
CLMgrLineStates.LSActive => "aktiv",
CLMgrLineStates.LSOnHold => "gehalten",
CLMgrLineStates.LSConferenceActive => "Konferenz aktiv",
CLMgrLineStates.LSConferenceOnHold => "Konferenz gehalten",
CLMgrLineStates.LSTerminated => "beendet",
CLMgrLineStates.LSTransferring => "wird verbunden",
CLMgrLineStates.LSDisabled => "deaktiviert",
CLMgrLineStates.LSDirectCall => "Direktansprache",
CLMgrLineStates.LSActiveAlerting => "zweiter Ruf",
CLMgrLineStates.LSActiveAlertingDC => "zweiter Ruf (Direktansprache)",
CLMgrLineStates.LSNone => "unbekannt",
_ => state.ToString().StartsWith("LSWaitingFor", StringComparison.Ordinal)
? "wird geschaltet"
: state.ToString()
};
}
+28
View File
@@ -0,0 +1,28 @@
namespace SwyxTray.Swyx;
/// <summary>
/// Ausschnitt aus <c>PubCLMgrMessages</c> (CLMgrPubTypes.h). Diese Aufzaehlung
/// steckt nur im C-Header des SDK, nicht in der TypeLib, und ist deshalb hier
/// nachgebildet — sie liefert die Bedeutung des <c>msg</c>-Parameters von
/// <c>IClientLineMgrEventsPub.PubOnLineMgrNotification</c>.
///
/// Bewusst nur die Werte, die hier gebraucht werden. Der Zustand wird bei
/// JEDER Benachrichtigung neu gelesen, nicht nur bei den bekannten Werten —
/// ein falsch zugeordneter Wert kann die Anzeige also nicht verfaelschen,
/// er beeinflusst nur den Protokolltext.
/// </summary>
internal enum PubCLMgrMessage
{
LineStateChanged = 0,
LineSelectionChanged = 1,
LineDetailsChanged = 2,
UserDataChanged = 3,
CallDetails = 4,
ServerDown = 5,
ServerUp = 6,
WaveDeviceChanged = 7,
GroupCallNotification = 8,
NameKeyStateChanged = 9,
NumberOfLinesChanged = 10,
ClientShutDownRequest = 11
}
+476
View File
@@ -0,0 +1,476 @@
using System.Runtime.InteropServices;
using System.Text;
using IpPbx.CLMgrLib;
namespace SwyxTray.Swyx;
/// <summary>
/// Verbindung zum lokal laufenden SwyxIt!-Client ueber den Client Line Manager
/// (CLMgr.exe) mittels der offiziellen Interop-Wrapper aus dem NuGet-Paket
/// <c>Swyx.Client.ClmgrAPI</c>.
///
/// CLMgr.exe ist ein Out-of-Process-COM-Server. Beim Erzeugen von
/// <see cref="ClientLineMgrClass"/> verbindet sich der Prozess mit der bereits
/// laufenden CLMgr-Instanz der Sitzung — es wird also kein zweiter Client
/// gestartet, sondern der vorhandene mitbenutzt.
///
/// Alle Zugriffe laufen ueber den UI-Thread der Anwendung. Das ist Absicht:
/// CLMgr ist ein STA-COM-Server, und der WinForms-Thread ist STA und besitzt
/// bereits eine Nachrichtenschleife. Damit werden auch die COM-Ereignisse
/// automatisch dorthin gemarshallt und jedes manuelle Marshalling entfaellt.
/// </summary>
internal sealed class SwyxClient : IDisposable
{
/// <summary>Anmeldename gegenueber CLMgr; leer = angemeldeter Benutzer.</summary>
private const string RegisterUserName = "";
private ClientLineMgrClass? _mgr;
private IClientLineMgrDisp? _disp;
private IClientLineMgrEventsPub_Event? _events;
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler;
private int _userId = -1;
private string _lastSignature = string.Empty;
private bool _disposed;
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
public bool IsConnected => _disp is not null;
/// <summary>
/// Ob der Ereignis-Rueckkanal steht. Ist er es nicht, muss haeufiger
/// abgefragt werden, damit die Anzeige nicht traege wirkt.
/// </summary>
public bool EventsActive { get; private set; }
/// <summary>
/// Stellt die Verbindung her, falls sie noch nicht besteht, und liest den
/// Zustand ein. Wirft nicht — Fehler landen im Protokoll.
/// Wird beim Start und danach zyklisch als Sicherheitsnetz aufgerufen.
/// </summary>
public void EnsureConnectedAndRefresh()
{
if (_disposed)
{
return;
}
if (_disp is null && !TryConnect())
{
Publish(SwyxSnapshot.Offline);
return;
}
Refresh();
}
private bool TryConnect()
{
ClientLineMgrClass? mgr = null;
try
{
mgr = new ClientLineMgrClass();
var disp = (IClientLineMgrDisp)mgr;
// DispInit meldet die Anwendung beim Line Manager an. Leerer
// Servername = vorhandene Konfiguration des laufenden Clients
// uebernehmen. Schlaegt das fehl, ist der Client meist noch im
// Hochlauf — dann beim naechsten Zyklus erneut versuchen.
disp.DispInit(string.Empty);
_userId = disp.DispRegisterUser(RegisterUserName);
_mgr = mgr;
_disp = disp;
mgr = null; // Besitz uebernommen, nicht mehr freigeben
HookEvents(_mgr);
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
return true;
}
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
{
Log.Debug($"Verbindung zu CLMgr fehlgeschlagen (0x{ex.HResult:X8}): {ex.Message}");
Cleanup();
return false;
}
catch (InvalidCastException ex)
{
// CLMgr.exe ist ein Out-of-Process-Server: jedes QueryInterface
// laedt den Proxy-Stub CLMgrPs64.dll (bzw. CLMgrPs.dll bei x86) in
// diesen Prozess. Wird der von einer Anwendungssteuerungsrichtlinie
// blockiert, schlaegt die Umwandlung mit 0x800711C7 fehl — das
// sieht aus wie eine Versionsinkompatibilitaet, ist aber keine.
var isPolicyBlock = ex.Message.Contains("0x800711C7", StringComparison.Ordinal);
Log.Error(isPolicyBlock
? "Der COM-Proxy CLMgrPs64.dll wurde von einer Anwendungssteuerungsrichtlinie " +
"blockiert (0x800711C7). Smart App Control bzw. die WDAC-Richtlinie pruefen; " +
"eine Aenderung an Smart App Control wirkt erst nach einem Neustart."
: "CLMgr liefert nicht die erwarteten Schnittstellen. Passt die SwyxIt!-Version " +
"zur Paketversion von Swyx.Client.ClmgrAPI?", ex);
Cleanup();
return false;
}
finally
{
if (mgr is not null)
{
Marshal.FinalReleaseComObject(mgr);
}
}
}
/// <summary>
/// Meldet die Ereignissenke an. Schlaegt das fehl, bleibt die Verbindung
/// bestehen — die Anzeige wird dann allein ueber den Abfragezyklus
/// aktualisiert. Ein fehlender Rueckkanal ist ein Komfortverlust, kein
/// Grund, die Verbindung zu verwerfen.
/// </summary>
private void HookEvents(ClientLineMgrClass mgr)
{
try
{
var events = (IClientLineMgrEventsPub_Event)mgr;
var handler = new IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler(
OnLineMgrNotification);
events.PubOnLineMgrNotification += handler;
_events = events;
_handler = handler;
EventsActive = true;
}
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException or InvalidCastException)
{
EventsActive = false;
_events = null;
_handler = null;
var hint = ex is UnauthorizedAccessException
? " CLMgr darf nicht in diesen Prozess zurueckrufen — pruefen, ob " +
"CoInitializeSecurity vor dem ersten COM-Aufruf gelaufen ist und ob " +
"CLMgr in derselben Sitzung und unter demselben Konto laeuft."
: string.Empty;
Log.Error("Ereignissenke konnte nicht angemeldet werden; es wird nur noch " +
"zyklisch abgefragt." + hint, ex);
}
}
/// <summary>
/// Zentrale Benachrichtigung des Line Managers. Bei JEDER Meldung wird der
/// Zustand neu gelesen — das ist billig (wenige COM-Zugriffe) und macht die
/// Anzeige unabhaengig davon, ob eine Meldungsnummer korrekt zugeordnet ist.
/// </summary>
private void OnLineMgrNotification(int msg, int param)
{
if (_disposed)
{
return;
}
var known = Enum.IsDefined(typeof(PubCLMgrMessage), msg)
? ((PubCLMgrMessage)msg).ToString()
: $"Msg{msg}";
Log.Debug($"Benachrichtigung {known} (msg={msg}, param={param})");
if (msg == (int)PubCLMgrMessage.ClientShutDownRequest)
{
Log.Info("CLMgr faehrt herunter — Verbindung wird getrennt.");
Cleanup();
Publish(SwyxSnapshot.Offline);
return;
}
Refresh();
}
private void Refresh()
{
var disp = _disp;
if (disp is null)
{
return;
}
try
{
Publish(ReadSnapshot(disp));
}
catch (COMException ex)
{
Log.Info($"Verbindung zu CLMgr verloren (0x{ex.HResult:X8}): {ex.Message}");
Cleanup();
Publish(SwyxSnapshot.Offline);
}
}
private static SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
{
var lineCount = disp.DispNumberOfLines;
var selected = disp.DispSelectedLineNumber;
var lines = new List<SwyxLineInfo>(Math.Max(0, lineCount));
for (var i = 0; i < lineCount; i++)
{
// DispGetLine liefert null, wenn die Leitung nicht verfuegbar ist.
if (disp.DispGetLine(i) is not IClientLineDisp line)
{
continue;
}
try
{
lines.Add(new SwyxLineInfo(
Index: i,
State: (CLMgrLineStates)line.DispState,
PeerNumber: line.DispPeerNumber ?? string.Empty,
PeerName: line.DispPeerName ?? string.Empty,
IsSelected: i == selected));
}
finally
{
Marshal.FinalReleaseComObject(line);
}
}
return new SwyxSnapshot(
IsConnected: true,
IsServerUp: disp.DispIsServerUp != 0,
UserName: disp.DispGetCurrentUser ?? string.Empty,
ServerName: disp.DispGetCurrentServer ?? string.Empty,
Lines: lines);
}
private void Publish(SwyxSnapshot snapshot)
{
var signature = BuildSignature(snapshot);
if (signature == _lastSignature)
{
return;
}
_lastSignature = signature;
var previous = Current;
Current = snapshot;
Log.Debug(snapshot.IsConnected
? $"Zustand: {snapshot.Overall} | " + string.Join(" | ", snapshot.Lines.Select(l =>
$"L{l.Index + 1}={l.State}({(int)l.State})" +
(l.IsBusy ? $" {l.PeerDisplayLong}" : string.Empty) +
(l.IsSelected ? " *" : string.Empty)))
: "Zustand: getrennt");
SnapshotChanged?.Invoke(previous, snapshot);
}
/// <summary>
/// Vergleichsschluessel. Records mit Listen vergleichen die Liste per
/// Referenz, deshalb eine eigene, stabile Signatur.
/// </summary>
private static string BuildSignature(SwyxSnapshot s)
{
var sb = new StringBuilder()
.Append(s.IsConnected).Append('|')
.Append(s.IsServerUp).Append('|')
.Append(s.UserName).Append('|')
.Append(s.ServerName);
foreach (var l in s.Lines)
{
sb.Append("||").Append(l.Index).Append(':')
.Append((int)l.State).Append(':')
.Append(l.PeerNumber).Append(':')
.Append(l.PeerName).Append(':')
.Append(l.IsSelected ? '1' : '0');
}
return sb.ToString();
}
// ---------------------------------------------------------------------
// Steuerung. Diese Methoden werden vom WebSocket-Zugang aufgerufen, laufen
// aber wie alles andere im UI-Thread (siehe UiDispatcher). Sie werfen bei
// Fehlern; der Aufrufer macht daraus eine Fehlerantwort an den Client.
// ---------------------------------------------------------------------
/// <summary>
/// Waehlt eine Rufnummer auf einer freien Leitung. Liefert die belegte
/// Leitung (1-basiert), sofern sie sich sofort erkennen laesst.
/// </summary>
public int? Dial(string number)
{
var disp = RequireConnection();
var dialstring = SanitizeNumber(number);
Log.Info($"Waehlt '{dialstring}'.");
// Die Ex-Varianten haben einen undokumentierten Rueckgabewert; die
// einfache Form meldet einen Fehler sauber als COMException. Der Aufruf
// ist laut SDK ohnehin asynchron — welche Leitung tatsaechlich belegt
// wird und ob der Ruf durchkommt, zeigt erst der Folgezustand.
disp.DispSimpleDial(dialstring);
Refresh();
var line = Current.Lines.FirstOrDefault(l => l.State.IsDialing());
return line?.Index + 1;
}
/// <summary>Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert).</summary>
public int Answer(int? line)
{
var index = ResolveLine(line, l => l.State.IsRinging(), "Es klingelt keine Leitung.");
WithLine(index, l => l.DispHookOff());
Refresh();
return index + 1;
}
/// <summary>Legt auf; liefert die Leitung (1-basiert).</summary>
public int Hangup(int? line)
{
var index = ResolveLine(line, l => l.IsBusy, "Es ist keine Leitung belegt.");
WithLine(index, l => l.DispHookOn());
Refresh();
return index + 1;
}
/// <summary>Stellt ein Gespraech in die Warteschleife.</summary>
public int Hold(int? line)
{
var index = ResolveLine(line, l => l.State.IsActive(), "Es ist kein Gespraech aktiv.");
WithLine(index, l => l.DispHold());
Refresh();
return index + 1;
}
private IClientLineMgrDisp RequireConnection()
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
/// <summary>
/// Ermittelt die zu bedienende Leitung. Ohne Angabe wird die passende
/// gesucht — bevorzugt die ausgewaehlte, damit die Bedienung ueber den
/// Browser und ueber den Client zum selben Ergebnis fuehren.
/// </summary>
private int ResolveLine(int? oneBased, Func<SwyxLineInfo, bool> matches, string noneFound)
{
if (oneBased is { } number)
{
var wanted = Current.Lines.FirstOrDefault(l => l.Index == number - 1)
?? throw new ArgumentException($"Leitung {number} gibt es nicht.");
return wanted.Index;
}
var candidates = Current.Lines.Where(matches).ToList();
var line = candidates.FirstOrDefault(l => l.IsSelected) ?? candidates.FirstOrDefault();
return line?.Index ?? throw new InvalidOperationException(noneFound);
}
private void WithLine(int index, Action<IClientLineDisp> action)
{
var disp = RequireConnection();
if (disp.DispGetLine(index) is not IClientLineDisp line)
{
throw new InvalidOperationException($"Leitung {index + 1} ist nicht verfuegbar.");
}
try
{
action(line);
}
finally
{
Marshal.FinalReleaseComObject(line);
}
}
/// <summary>
/// Aus dem Web kommen Nummern mit Leerzeichen, Bindestrichen und Klammern.
/// Die werden entfernt; alles darueber hinaus wird abgewiesen, statt es an
/// CLMgr weiterzureichen — der Wahlstring darf keine Ueberraschungen aus
/// einer fremden Webseite enthalten.
/// </summary>
public static string SanitizeNumber(string? number)
{
var cleaned = new string((number ?? string.Empty)
.Where(c => !char.IsWhiteSpace(c) && c is not ('-' or '(' or ')' or '.' or '/'))
.ToArray());
var isValid = cleaned.Length is > 0 and <= 64
&& cleaned.All(c => char.IsAsciiDigit(c) || c is '+' or '*' or '#')
&& cleaned.IndexOf('+') <= 0;
return isValid
? cleaned
: throw new ArgumentException($"'{number}' ist keine gueltige Rufnummer.");
}
/// <summary>Verbindung verwerfen und beim naechsten Zyklus neu aufbauen.</summary>
public void Reconnect()
{
Cleanup();
_lastSignature = string.Empty;
EnsureConnectedAndRefresh();
}
private void Cleanup()
{
if (_events is not null && _handler is not null)
{
try
{
_events.PubOnLineMgrNotification -= _handler;
}
catch (COMException)
{
// Server bereits weg — Abmelden ist dann gegenstandslos.
}
}
if (_disp is not null && _userId >= 0)
{
try
{
_disp.DispReleaseUser(_userId);
}
catch (COMException)
{
// dito
}
}
if (_mgr is not null)
{
try
{
Marshal.FinalReleaseComObject(_mgr);
}
catch (Exception ex) when (ex is COMException or InvalidComObjectException or ArgumentException)
{
// Bereits freigegeben ist beim Abbau kein Fehler.
}
}
_handler = null;
_events = null;
_disp = null;
_mgr = null;
_userId = -1;
EventsActive = false;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Cleanup();
}
}
+118
View File
@@ -0,0 +1,118 @@
using IpPbx.CLMgrLib;
namespace SwyxTray.Swyx;
/// <summary>
/// Aggregierter Zustand ueber alle Leitungen — das, was das Tray-Icon anzeigt.
/// </summary>
public enum SwyxOverallState
{
/// <summary>CLMgr nicht erreichbar (SwyxIt! laeuft nicht).</summary>
Offline,
/// <summary>Verbunden, aber der SwyxServer meldet sich nicht.</summary>
ServerDown,
/// <summary>Verbunden, keine belegte Leitung.</summary>
Idle,
/// <summary>Mindestens eine Leitung klingelt (eingehend).</summary>
Ringing,
/// <summary>Ausgehender Ruf im Aufbau.</summary>
Dialing,
/// <summary>Mindestens ein Gespraech aktiv.</summary>
InCall,
/// <summary>Gespraeche nur noch gehalten.</summary>
OnHold
}
/// <summary>Momentaufnahme einer Leitung.</summary>
public sealed record SwyxLineInfo(
int Index,
CLMgrLineStates State,
string PeerNumber,
string PeerName,
bool IsSelected)
{
/// <summary>Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.</summary>
public string PeerDisplay =>
!string.IsNullOrWhiteSpace(PeerName) ? PeerName
: !string.IsNullOrWhiteSpace(PeerNumber) ? PeerNumber
: "unbekannt";
/// <summary>Name und Nummer, sofern beides bekannt und verschieden.</summary>
public string PeerDisplayLong =>
!string.IsNullOrWhiteSpace(PeerName) && !string.IsNullOrWhiteSpace(PeerNumber)
? $"{PeerName} ({PeerNumber})"
: PeerDisplay;
public bool IsBusy => !State.IsIdle();
}
/// <summary>Momentaufnahme des gesamten Clients.</summary>
public sealed record SwyxSnapshot(
bool IsConnected,
bool IsServerUp,
string UserName,
string ServerName,
IReadOnlyList<SwyxLineInfo> Lines)
{
public static SwyxSnapshot Offline { get; } =
new(false, false, string.Empty, string.Empty, Array.Empty<SwyxLineInfo>());
/// <summary>Leitungen mit Gespraech bzw. Gespraechsaufbau.</summary>
public IEnumerable<SwyxLineInfo> BusyLines => Lines.Where(l => l.IsBusy);
public SwyxOverallState Overall
{
get
{
if (!IsConnected)
{
return SwyxOverallState.Offline;
}
if (!IsServerUp)
{
return SwyxOverallState.ServerDown;
}
if (Lines.Any(l => l.State.IsRinging()))
{
return SwyxOverallState.Ringing;
}
if (Lines.Any(l => l.State.IsActive()))
{
return SwyxOverallState.InCall;
}
if (Lines.Any(l => l.State.IsDialing()))
{
return SwyxOverallState.Dialing;
}
if (Lines.Any(l => l.State.IsOnHold()))
{
return SwyxOverallState.OnHold;
}
return SwyxOverallState.Idle;
}
}
/// <summary>Kurztext fuer QuickInfo und Kontextmenue-Kopf.</summary>
public string StatusText => Overall switch
{
SwyxOverallState.Offline => "SwyxIt! nicht verbunden",
SwyxOverallState.ServerDown => "SwyxServer nicht erreichbar",
SwyxOverallState.Ringing => "Eingehender Ruf",
SwyxOverallState.Dialing => "Verbindungsaufbau",
SwyxOverallState.InCall => "Im Gespraech",
SwyxOverallState.OnHold => "Gespraech gehalten",
_ => "Bereit"
};
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WinExe = kein Konsolenfenster. Die App hat kein Hauptfenster,
nur ein NotifyIcon im Infobereich der Taskleiste. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SwyxTray</RootNamespace>
<AssemblyName>SwyxTray</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- Das Paket Swyx.Client.ClmgrAPI waehlt die Interop-Assembly ueber
$(Platform) aus und bricht bei 'AnyCPU' mit einem Fehler ab.
x64 ist moeglich, weil CLMgr.exe ein Out-of-Process-COM-Server ist
(LocalServer32) und der 64-Bit-Proxy CLMgrPs64.dll mitinstalliert wird.
Fuer 32 Bit einfach auf x86 umstellen. -->
<Platforms>x64;x86</Platforms>
<Platform>x64</Platform>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- Offizieller Wrapper von Enreach: Interop-Assemblies der
ClientLineManager-COM-API. Version passend zur SwyxIt!-Version. -->
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
</ItemGroup>
</Project>
+506
View File
@@ -0,0 +1,506 @@
using System.Diagnostics;
using SwyxTray.Swyx;
using SwyxTray.Web;
namespace SwyxTray;
/// <summary>
/// Der eigentliche Anwendungsrumpf: kein Fenster, nur ein NotifyIcon im
/// Infobereich der Taskleiste samt Kontextmenue.
/// </summary>
internal sealed class TrayApplicationContext : ApplicationContext
{
/// <summary>
/// Sicherheitsnetz neben den COM-Ereignissen: faengt einen zwischenzeitlich
/// beendeten CLMgr ab und stellt die Verbindung wieder her. Die eigentliche
/// Aktualisierung laeuft ereignisgesteuert, nicht ueber diesen Zyklus.
/// </summary>
private static readonly TimeSpan SafetyInterval = TimeSpan.FromSeconds(5);
/// <summary>
/// Kuerzeres Intervall, wenn der Ereignis-Rueckkanal nicht steht — dann ist
/// die Abfrage die einzige Quelle fuer Zustandsaenderungen.
/// </summary>
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
private readonly NotifyIcon _notifyIcon;
private readonly SwyxClient _client;
private readonly System.Windows.Forms.Timer _safetyTimer;
private readonly ToolStripMenuItem _statusItem;
private readonly ToolStripMenuItem _linesItem;
private readonly ToolStripSeparator _linesSeparator;
private readonly ToolStripMenuItem _webSocketItem;
private readonly ToolStripMenuItem _copyEndpointItem;
private readonly ToolStripMenuItem _readTabsItem;
private readonly ToolStripMenuItem _openTabItem;
private readonly ToolStripMenuItem _closeTabItem;
/// <summary>Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen".</summary>
private const string AssecutorUrl = "https://www.assecutor.de";
private const string AssecutorTitle = "Assecutor Data Service GmbH";
private LocalWebSocketServer? _server;
private bool _disposed;
public TrayApplicationContext()
{
_statusItem = new ToolStripMenuItem("Wird verbunden …") { Enabled = false };
_linesItem = new ToolStripMenuItem("Leitungen") { Visible = false };
_linesSeparator = new ToolStripSeparator { Visible = false };
_copyEndpointItem = new ToolStripMenuItem(
"Verbindungsdaten kopieren", null, (_, _) => CopyEndpoint()) { Enabled = false };
_webSocketItem = new ToolStripMenuItem("WebSocket-Zugang");
_webSocketItem.DropDownItems.AddRange(new ToolStripItem[]
{
_copyEndpointItem,
new ToolStripMenuItem("Konfiguration oeffnen", null,
(_, _) => OpenWithShell(WebSocketConfig.FilePath))
});
_readTabsItem = new ToolStripMenuItem(
"Lese Tabs", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, ReadTabsAsync)) { Enabled = false };
_openTabItem = new ToolStripMenuItem(
"Tab oeffnen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, OpenAssecutorTabAsync)) { Enabled = false };
_closeTabItem = new ToolStripMenuItem(
"Tab schliessen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, CloseAssecutorTabAsync)) { Enabled = false };
var menu = new ContextMenuStrip();
menu.Items.AddRange(new ToolStripItem[]
{
_statusItem,
_linesItem,
_linesSeparator,
_webSocketItem,
_readTabsItem,
_openTabItem,
_closeTabItem,
new ToolStripSeparator(),
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
new ToolStripSeparator(),
new ToolStripMenuItem("Beenden", null, (_, _) => ExitApplication())
});
_notifyIcon = new NotifyIcon
{
Icon = TrayIcons.For(SwyxOverallState.Offline),
Text = "SwyxTray — wird verbunden",
ContextMenuStrip = menu,
Visible = true
};
_notifyIcon.DoubleClick += (_, _) => ShowStatusBalloon();
_client = new SwyxClient();
_client.SnapshotChanged += OnSnapshotChanged;
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
_safetyTimer.Tick += (_, _) =>
{
_client.EnsureConnectedAndRefresh();
UpdateWebSocketMenu();
var wanted = (int)(_client.IsConnected && !_client.EventsActive
? PollingOnlyInterval
: SafetyInterval).TotalMilliseconds;
if (_safetyTimer!.Interval != wanted)
{
_safetyTimer.Interval = wanted;
}
};
_safetyTimer.Start();
// Erster Verbindungsversuch, sobald die Nachrichtenschleife laeuft.
// Direkt im Konstruktor waere er zu frueh — COM-Ereignisse brauchen
// die Schleife, und ein Fehler hier wuerde den Start abbrechen.
// Der WebSocket-Zugang startet aus demselben Grund erst hier: sein
// UiDispatcher braucht den SynchronizationContext der Schleife.
BeginInvokeOnMessageLoop(() =>
{
_client.EnsureConnectedAndRefresh();
StartWebSocketServer();
});
}
private void StartWebSocketServer()
{
if (WebSocketConfig.Load() is not { } config)
{
UpdateWebSocketMenu();
return;
}
if (UiDispatcher.Capture() is not { } dispatcher)
{
Log.Error("Kein UI-Kontext vorhanden — der WebSocket-Zugang bleibt aus.");
UpdateWebSocketMenu();
return;
}
var server = new LocalWebSocketServer(config, _client, dispatcher);
server.Start();
if (server.IsRunning)
{
_server = server;
_server.Broadcast(_client.Current);
}
else
{
server.Dispose();
}
UpdateWebSocketMenu();
}
private void UpdateWebSocketMenu()
{
// Der Hinweis auf die abgeschaltete Pruefung gehoert sichtbar ins
// Menue: sonst waere einer Instanz nicht anzusehen, dass sie jeden
// hereinlaesst.
var debugHinweis = WebSocketConfig.SecurityDisabled ? " — OHNE PRUEFUNG" : string.Empty;
var netzHinweis = _server is { IsRemote: true } ? " — im Netz" : string.Empty;
_webSocketItem.Text = _server is { IsRunning: true } server
? $"WebSocket: Port {server.Port}" +
(server.PluginPort > 0 ? $" +{server.PluginPort}" : string.Empty) +
$", {server.ConnectionCount} Verbindung(en){netzHinweis}{debugHinweis}"
: "WebSocket: aus (siehe Protokoll)";
_copyEndpointItem.Enabled = _server is { IsRunning: true };
_readTabsItem.Enabled = _openTabItem.Enabled = _closeTabItem.Enabled =
_server is { IsRunning: true, PluginPort: > 0 };
}
/// <summary>
/// Gemeinsamer Rahmen der Tab-Menuepunkte: waehrend der Auftrag beim
/// Firefox-Plugin laeuft, ist der Punkt gesperrt — sonst stiesse ein
/// zweiter Klick eine zweite Anfrage an. async void ist hier richtig:
/// ein Menue-Handler hat keinen Aufrufer, der auf ein Task warten koennte.
/// </summary>
private async void RunTabAction(ToolStripMenuItem item, Func<LocalWebSocketServer, Task> action)
{
if (_server is not { IsRunning: true } server)
{
return;
}
item.Enabled = false;
try
{
await action(server);
}
finally
{
if (!_disposed)
{
item.Enabled = true;
}
}
}
/// <summary>
/// Holt die offenen Tabs vom Firefox-Plugin (ueber den Plugin-Port) und
/// zeigt ihre Titel als Sprechblase; die vollstaendige Liste samt Adressen
/// steht im Protokoll.
/// </summary>
private async Task ReadTabsAsync(LocalWebSocketServer server)
{
var tabs = await server.TryListTabsAsync(CancellationToken.None);
if (_disposed)
{
return;
}
if (tabs is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
if (tabs.Count == 0)
{
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs", "Keine Tabs offen.", ToolTipIcon.Info);
return;
}
Log.Info($"Firefox meldet {tabs.Count} offene(n) Tab(s): " + string.Join(" | ",
tabs.Select(t => $"{(t.Active == true ? "*" : "")}{t.Title} <{t.Url}>")));
// Eine Sprechblase fasst hoechstens 255 Zeichen — die ersten Titel
// genuegen, der Rest steht im Protokoll.
const int maxShown = 5;
var lines = tabs.Take(maxShown)
.Select(t => Truncate((t.Active == true ? "▶ " : "• ") + (t.Title ?? "(ohne Titel)"), 40))
.ToList();
if (tabs.Count > maxShown)
{
lines.Add($"… und {tabs.Count - maxShown} weitere (siehe Protokoll)");
}
_notifyIcon.ShowBalloonTip(10_000, $"{tabs.Count} offene(r) Firefox-Tab(s)",
Truncate(string.Join(Environment.NewLine, lines), 255), ToolTipIcon.Info);
}
/// <summary>Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite.</summary>
private async Task OpenAssecutorTabAsync(LocalWebSocketServer server)
{
var tabId = await server.TryOpenTabAsync(AssecutorUrl, CancellationToken.None);
if (_disposed)
{
return;
}
if (tabId is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
Log.Info($"Firefox-Tab {tabId} mit {AssecutorUrl} geoeffnet.");
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen", $"{AssecutorUrl} geoeffnet.", ToolTipIcon.Info);
}
/// <summary>
/// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando
/// als Komposition aus den Grundauftraegen des Kanals: erst <c>list</c>,
/// dann je Treffer ein <c>close</c> mit der Tab-Id. So laesst sich auch
/// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt.
/// </summary>
private async Task CloseAssecutorTabAsync(LocalWebSocketServer server)
{
var tabs = await server.TryListTabsAsync(CancellationToken.None);
if (_disposed)
{
return;
}
if (tabs is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
var matching = tabs
.Where(t => t.Id is not null
&& t.Title?.Contains(AssecutorTitle, StringComparison.OrdinalIgnoreCase) == true)
.ToList();
if (matching.Count == 0)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
$"Kein Tab \"{AssecutorTitle}\" offen.", ToolTipIcon.Warning);
return;
}
var closed = 0;
foreach (var tab in matching)
{
if (await server.TryCloseTabAsync(tab.Id!.Value, CancellationToken.None))
{
closed++;
}
}
if (_disposed)
{
return;
}
Log.Info($"Firefox-Tab(s) \"{AssecutorTitle}\": {closed} von {matching.Count} geschlossen.");
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
closed == matching.Count
? $"Tab \"{AssecutorTitle}\" geschlossen."
: $"Nur {closed} von {matching.Count} Tabs geschlossen (siehe Protokoll).",
closed == matching.Count ? ToolTipIcon.Info : ToolTipIcon.Warning);
}
/// <summary>
/// Legt Adresse samt Token in die Zwischenablage — beim Einrichten einer
/// Seite ist das der einzige Wert, den jemand von Hand uebertragen muss.
/// </summary>
private void CopyEndpoint()
{
if (_server is not { IsRunning: true } server)
{
return;
}
try
{
Clipboard.SetText(server.Endpoint);
_notifyIcon.ShowBalloonTip(5_000, "WebSocket-Zugang",
"Adresse und Token liegen in der Zwischenablage.", ToolTipIcon.Info);
}
catch (System.Runtime.InteropServices.ExternalException ex)
{
Log.Error("Zwischenablage nicht verfuegbar.", ex);
}
}
/// <summary>
/// Fuehrt eine Aktion aus, sobald die Nachrichtenschleife laeuft.
/// Ein Timer mit kurzem Intervall ist hier das einfachste Mittel, weil
/// ohne Fenster kein Control fuer BeginInvoke zur Verfuegung steht.
/// </summary>
private static void BeginInvokeOnMessageLoop(Action action)
{
var starter = new System.Windows.Forms.Timer { Interval = 1 };
starter.Tick += (s, _) =>
{
var timer = (System.Windows.Forms.Timer)s!;
timer.Stop();
timer.Dispose();
action();
};
starter.Start();
}
private void OnSnapshotChanged(SwyxSnapshot previous, SwyxSnapshot current)
{
UpdateIcon(current);
UpdateMenu(current);
NotifyAboutNewCalls(previous, current);
_server?.Broadcast(previous, current);
}
private void UpdateIcon(SwyxSnapshot s)
{
_notifyIcon.Icon = TrayIcons.For(s.Overall);
var busy = s.BusyLines.ToList();
var detail = busy.Count switch
{
0 => s.IsConnected ? $"{s.UserName} @ {s.ServerName}" : "SwyxIt! nicht verbunden",
1 => $"{busy[0].State.ToDisplayText()}: {busy[0].PeerDisplay}",
_ => $"{busy.Count} belegte Leitungen"
};
// Die QuickInfo eines NotifyIcon ist auf 63 Zeichen begrenzt; laengerer
// Text wird von Windows kommentarlos verworfen.
_notifyIcon.Text = Truncate($"Swyx — {s.StatusText}\n{detail}", 63);
}
private void UpdateMenu(SwyxSnapshot s)
{
_statusItem.Text = s.IsConnected
? $"{s.StatusText} — {s.UserName}"
: s.StatusText;
var busy = s.BusyLines.ToList();
_linesItem.DropDownItems.Clear();
foreach (var line in busy)
{
var text = $"Leitung {line.Index + 1}: {line.State.ToDisplayText()} — {line.PeerDisplayLong}";
_linesItem.DropDownItems.Add(new ToolStripMenuItem(text) { Enabled = false });
}
_linesItem.Text = busy.Count == 1 ? "1 belegte Leitung" : $"{busy.Count} belegte Leitungen";
_linesItem.Visible = busy.Count > 0;
_linesSeparator.Visible = busy.Count > 0;
}
/// <summary>
/// Sprechblase nur fuer Leitungen, die neu zu klingeln beginnen — nicht bei
/// jedem Zustandswechsel, sonst wird der Benutzer waehrend eines Gespraechs
/// mehrfach benachrichtigt.
/// </summary>
private void NotifyAboutNewCalls(SwyxSnapshot previous, SwyxSnapshot current)
{
var previouslyRinging = previous.Lines
.Where(l => l.State.IsRinging())
.Select(l => l.Index)
.ToHashSet();
foreach (var line in current.Lines.Where(l => l.State.IsRinging()))
{
if (previouslyRinging.Contains(line.Index))
{
continue;
}
Log.Info($"Eingehender Ruf auf Leitung {line.Index + 1}: {line.PeerDisplayLong}");
_notifyIcon.ShowBalloonTip(
10_000,
"Eingehender Ruf",
$"{line.PeerDisplayLong}\nLeitung {line.Index + 1}",
ToolTipIcon.Info);
}
}
private void ShowStatusBalloon()
{
var s = _client.Current;
var busy = s.BusyLines.ToList();
var body = busy.Count == 0
? (s.IsConnected ? $"Angemeldet als {s.UserName} an {s.ServerName}." : "Keine Verbindung zu SwyxIt!.")
: string.Join(Environment.NewLine,
busy.Select(l => $"Leitung {l.Index + 1}: {l.State.ToDisplayText()} — {l.PeerDisplayLong}"));
_notifyIcon.ShowBalloonTip(5_000, s.StatusText, body, ToolTipIcon.Info);
}
private void Reconnect()
{
Log.Info("Neuverbindung durch Benutzer angefordert.");
_client.Reconnect();
}
private void OpenLog()
{
if (!File.Exists(Log.FilePath))
{
Log.Info("Protokoll auf Wunsch geoeffnet.");
}
OpenWithShell(Log.FilePath);
}
private static void OpenWithShell(string path)
{
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
{
Log.Error($"{path} konnte nicht geoeffnet werden.", ex);
}
}
private void ExitApplication()
{
// Symbol sofort ausblenden, sonst bleibt bis zum naechsten Ueberfahren
// mit der Maus eine Leiche im Infobereich stehen.
_notifyIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing && !_disposed)
{
_disposed = true;
_safetyTimer.Stop();
_safetyTimer.Dispose();
_server?.Dispose();
_client.SnapshotChanged -= OnSnapshotChanged;
_client.Dispose();
_notifyIcon.Visible = false;
_notifyIcon.ContextMenuStrip?.Dispose();
_notifyIcon.Dispose();
}
base.Dispose(disposing);
}
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..(max - 1)] + "…";
}
+90
View File
@@ -0,0 +1,90 @@
using System.Drawing.Drawing2D;
using SwyxTray.Swyx;
namespace SwyxTray;
/// <summary>
/// Erzeugt die Symbole zur Laufzeit (Kreis mit Hoererbogen), damit keine
/// .ico-Dateien mitgeliefert werden muessen. Wer eigene Symbole verwenden
/// will, ersetzt einfach <see cref="For"/> durch das Laden aus Ressourcen.
///
/// Die Symbole werden einmal je Zustand erzeugt und zwischengespeichert —
/// GDI-Handles sind eine begrenzte Ressource.
/// </summary>
internal static class TrayIcons
{
private static readonly Dictionary<SwyxOverallState, Icon> Cache = new();
private static readonly Dictionary<SwyxOverallState, Color> Colors = new()
{
[SwyxOverallState.Offline] = Color.FromArgb(0x9E, 0x9E, 0x9E), // grau
[SwyxOverallState.ServerDown] = Color.FromArgb(0xE5, 0x39, 0x35), // rot
[SwyxOverallState.Idle] = Color.FromArgb(0x43, 0xA0, 0x47), // gruen
[SwyxOverallState.Ringing] = Color.FromArgb(0x1E, 0x88, 0xE5), // blau
[SwyxOverallState.Dialing] = Color.FromArgb(0x00, 0xAC, 0xC1), // tuerkis
[SwyxOverallState.InCall] = Color.FromArgb(0xFB, 0x8C, 0x00), // orange
[SwyxOverallState.OnHold] = Color.FromArgb(0x8E, 0x24, 0xAA) // violett
};
public static Icon For(SwyxOverallState state)
{
if (Cache.TryGetValue(state, out var cached))
{
return cached;
}
var icon = Render(Colors.TryGetValue(state, out var c) ? c : Color.Gray);
Cache[state] = icon;
return icon;
}
private static Icon Render(Color color)
{
// 32x32 zeichnen und Windows herunterskalieren lassen — sieht auf
// hochaufloesenden Anzeigen besser aus als 16x16.
const int size = 32;
using var bitmap = new Bitmap(size, size);
using (var g = Graphics.FromImage(bitmap))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
using (var brush = new SolidBrush(color))
{
g.FillEllipse(brush, 1, 1, size - 3, size - 3);
}
// Hoerer: ein dicker Bogen mit runden Enden liest sich bei kleiner
// Darstellung zuverlaessiger als eine ausmodellierte Silhouette.
using var pen = new Pen(Color.White, 5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round
};
g.DrawArc(pen, 9f, 9f, 14f, 14f, 130f, 200f);
}
// Icon.FromHandle borgt sich das Handle nur; ueber Clone entsteht eine
// eigenstaendige Kopie, damit das HICON sofort freigegeben werden kann.
var hIcon = bitmap.GetHicon();
try
{
using var borrowed = Icon.FromHandle(hIcon);
return (Icon)borrowed.Clone();
}
finally
{
NativeMethods.DestroyIcon(hIcon);
}
}
public static void DisposeAll()
{
foreach (var icon in Cache.Values)
{
icon.Dispose();
}
Cache.Clear();
}
}
+196
View File
@@ -0,0 +1,196 @@
using System.Runtime.InteropServices;
using SwyxTray.Swyx;
namespace SwyxTray.Web;
/// <summary>
/// Setzt die Kommandos der Webseite in Aufrufe des <see cref="SwyxClient"/> um
/// — jeden davon im UI-Thread, weil dort die COM-Verbindung lebt.
/// </summary>
internal sealed class CommandExecutor
{
/// <summary>
/// Obergrenze fuer einen Sprung in den UI-Thread. Ohne sie wuerde eine
/// blockierte Nachrichtenschleife (etwa ein haengender COM-Aufruf) die
/// Verbindung stillschweigend einfrieren.
/// </summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private readonly SwyxClient _client;
private readonly UiDispatcher _ui;
private readonly PluginTabChannel _tabs;
public CommandExecutor(SwyxClient client, UiDispatcher ui, PluginTabChannel tabs)
{
_client = client;
_ui = ui;
_tabs = tabs;
}
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
=> RunAsync(() => _client.Current, cancellationToken);
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
{
try
{
switch (command.Cmd!.Trim().ToLowerInvariant())
{
case "ping":
return new ResultMessage(command.Id, true);
case "call":
return new ResultMessage(command.Id, true,
Line: await RunAsync(() => _client.Dial(command.Number ?? string.Empty), cancellationToken)
.ConfigureAwait(false));
case "answer":
return new ResultMessage(command.Id, true,
Line: await RunAsync(() => _client.Answer(command.Line), cancellationToken)
.ConfigureAwait(false));
case "hangup":
return new ResultMessage(command.Id, true,
Line: await RunAsync(() => _client.Hangup(command.Line), cancellationToken)
.ConfigureAwait(false));
case "hold":
return new ResultMessage(command.Id, true,
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
.ConfigureAwait(false));
case "focus":
return new ResultMessage(command.Id, true,
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
case "tabs":
return new ResultMessage(command.Id, true,
Tabs: await ListTabsAsync(cancellationToken).ConfigureAwait(false));
case "opentab":
return new ResultMessage(command.Id, true,
TabId: await OpenTabAsync(command, cancellationToken).ConfigureAwait(false));
case "closetab":
await CloseTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "reconnect":
await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken)
.ConfigureAwait(false);
return new ResultMessage(command.Id, true);
default:
return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'.");
}
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
// Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung,
// SwyxIt! nicht verbunden.
return new ResultMessage(command.Id, false, ex.Message);
}
catch (COMException ex)
{
Log.Error($"Kommando '{command.Cmd}' wurde von CLMgr abgewiesen.", ex);
return new ResultMessage(command.Id, false, $"SwyxIt! meldet 0x{ex.HResult:X8}.");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Log.Error($"Kommando '{command.Cmd}' lief in die Zeitgrenze — der UI-Thread antwortet nicht.");
return new ResultMessage(command.Id, false, "Zeitueberschreitung.");
}
}
/// <summary>
/// Aktiviert den zur Anfrage passenden Firefox-Tab ueber das Plugin
/// (<c>true</c> = der Tab war schon offen) oder oeffnet die Adresse als
/// neuen Tab (<c>false</c>). Ohne verbundenes Plugin scheitert das
/// Kommando — einen anderen Weg, eine Seite anzuzeigen, gibt es nicht.
/// </summary>
private async Task<bool> FocusAsync(ClientCommand command, CancellationToken cancellationToken)
{
var url = ValidateUrl(command.Url);
var title = command.Title?.Trim();
// Ein "Aktivieren" kennt der Tab-Kanal nicht — nach vorn kommt ein
// vorhandener Tab als Komposition der drei Operationen: Liste holen,
// den passenden Tab schliessen und die URL neu oeffnen (ein neuer Tab
// ist in Firefox von selbst vorn).
if (await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) is not { } openTabs)
{
throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es antwortet nicht.");
}
var match = openTabs.FirstOrDefault(t =>
(!string.IsNullOrEmpty(title)
&& (t.Title?.Contains(title, StringComparison.OrdinalIgnoreCase) ?? false))
|| string.Equals(t.Url, url, StringComparison.OrdinalIgnoreCase));
if (match?.Id is { } tabId)
{
await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false);
}
if (await _tabs.TryOpenTabAsync(url, cancellationToken).ConfigureAwait(false) is null)
{
throw new InvalidOperationException("Das Firefox-Plugin hat den Tab nicht geoeffnet.");
}
return match is not null; // true = der Tab war schon offen
}
/// <summary>Die offenen Tabs — die drei Tab-Kommandos des Hauptports
/// reichen die Auftraege unveraendert an das Firefox-Plugin durch.</summary>
private async Task<IReadOnlyList<TabInfo>> ListTabsAsync(CancellationToken cancellationToken)
=> await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es antwortet nicht.");
private async Task<int> OpenTabAsync(ClientCommand command, CancellationToken cancellationToken)
=> await _tabs.TryOpenTabAsync(ValidateUrl(command.Url), cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht geoeffnet.");
private async Task CloseTabAsync(ClientCommand command, CancellationToken cancellationToken)
{
if (command.TabId is not { } tabId)
{
throw new ArgumentException("Feld 'tabId' fehlt.");
}
if (!await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht geschlossen.");
}
}
/// <summary>
/// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz
/// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen.
/// </summary>
private static string ValidateUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
throw new ArgumentException("Feld 'url' fehlt.");
}
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException("Feld 'url' muss eine absolute http(s)-Adresse sein.");
}
return uri.AbsoluteUri;
}
private async Task<T> RunAsync<T>(Func<T> function, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(CommandTimeout);
return await _ui.RunAsync(function, timeout.Token).ConfigureAwait(false);
}
}
+398
View File
@@ -0,0 +1,398 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Net.WebSockets;
using SwyxTray.Swyx;
namespace SwyxTray.Web;
/// <summary>
/// WebSocket-Zugang der Tray-Anwendung: ein TcpListener auf der Loopback-
/// Adresse, ein von Hand beantworteter HTTP-Upgrade und danach die
/// WebSocket-Umsetzung der Klassenbibliothek.
///
/// Absichtlich kein HttpListener: der laeuft ueber http.sys und braucht auch
/// fuer 127.0.0.1 eine einmalige URL-Reservierung mit Administratorrechten.
/// Die Anwendung soll ohne solche Rechte auskommen.
///
/// Gebunden wird an 127.0.0.1 und ::1, mit
/// <see cref="WebSocketConfig.AllowRemoteAccess"/> stattdessen an alle
/// Schnittstellen. Solange es bei Loopback bleibt, ist der Zugang von aussen
/// nicht erreichbar und die Windows-Firewall fragt beim Start nichts nach.
/// </summary>
internal sealed class LocalWebSocketServer : IDisposable
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(10);
private readonly WebSocketConfig _config;
private readonly PluginTabChannel _tabChannel;
private readonly CommandExecutor _executor;
private readonly CancellationTokenSource _shutdown = new();
private readonly List<TcpListener> _listeners = new();
private readonly ConcurrentDictionary<int, WebSocketSession> _sessions = new();
private int _nextSessionId;
private string _host = "127.0.0.1";
public LocalWebSocketServer(WebSocketConfig config, SwyxClient client, UiDispatcher ui)
{
_config = config;
_tabChannel = new PluginTabChannel();
_executor = new CommandExecutor(client, ui, _tabChannel);
}
public bool IsRunning { get; private set; }
public int ConnectionCount => _sessions.Count;
public int Port => _config.Port;
/// <summary>Zweiter Port fuer das Firefox-Plugin; 0 = abgeschaltet.</summary>
public int PluginPort => _config.PluginPort;
/// <summary>
/// Adresse samt Token, wie sie eine Seite verwenden kann — bei Zugriff aus
/// dem Netz mit der IP-Adresse dieses Rechners statt 127.0.0.1.
/// </summary>
public string Endpoint => $"ws://{_host}:{_config.Port}/?token={_config.Token}";
public bool IsRemote => _config.AllowRemoteAccess;
/// <summary>
/// Fragt die offenen Tabs beim Firefox-Plugin ab. <c>null</c>, wenn kein
/// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt.
/// </summary>
public Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
=> _tabChannel.TryListTabsAsync(cancellationToken);
/// <summary>
/// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL.
/// <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
/// </summary>
public Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
=> _tabChannel.TryOpenTabAsync(url, cancellationToken);
/// <summary>
/// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id.
/// <c>true</c> nur bei bestaetigtem Erfolg.
/// </summary>
public Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
=> _tabChannel.TryCloseTabAsync(tabId, cancellationToken);
public void Start()
{
if (!_config.Enabled)
{
Log.Info("WebSocket-Zugang ist in der Konfiguration abgeschaltet.");
return;
}
var remote = _config.AllowRemoteAccess;
if (!TryListen(remote ? IPAddress.Any : IPAddress.Loopback, _config.Port, required: true))
{
return;
}
// Browser loesen "localhost" haeufig zuerst nach ::1 auf. Ohne diesen
// zweiten Listener scheitert eine Verbindung auf ws://localhost:… ,
// waehrend ws://127.0.0.1:… funktioniert — ein schwer zu deutender
// Unterschied. Bei IPv6Any gilt dasselbe fuer entfernte Rechner, die
// den Namen ueber IPv6 aufloesen.
TryListen(remote ? IPAddress.IPv6Any : IPAddress.IPv6Loopback, _config.Port, required: false);
// Derselbe Dienst noch einmal auf dem Plugin-Port — faellt er aus,
// laeuft der Hauptzugang unveraendert weiter.
if (_config.PluginPort > 0
&& TryListen(remote ? IPAddress.Any : IPAddress.Loopback, _config.PluginPort, required: false))
{
TryListen(remote ? IPAddress.IPv6Any : IPAddress.IPv6Loopback, _config.PluginPort, required: false);
}
IsRunning = true;
var addresses = remote ? LocalAddresses() : ["127.0.0.1"];
_host = addresses[0];
var reach = remote
? $"aus dem Netz erreichbar unter {string.Join(", ", addresses.Select(a => $"ws://{a}:{_config.Port}/"))}"
: $"bereit auf ws://127.0.0.1:{_config.Port}/";
if (_config.PluginPort > 0)
{
reach += $" (Firefox-Plugin: Port {_config.PluginPort})";
}
if (WebSocketConfig.SecurityDisabled)
{
Log.Info($"WebSocket-Zugang {reach} — OHNE PRUEFUNG: Token, Origin und Host werden " +
"nicht geprueft. " + (remote
? "Jeder im selben Netz kann telefonieren und mitlesen."
: "Jede im Browser geoeffnete Seite kann telefonieren und mitlesen.") +
" Nicht in diesem Zustand ausliefern.");
return;
}
Log.Info($"WebSocket-Zugang {reach} " +
$"({_config.AllowedOrigins.Count} erlaubte Origin(s), Token in {WebSocketConfig.FilePath}).");
if (remote)
{
Log.Info("Zugriff aus dem Netz ist eingeschaltet. Der Zugang haengt damit allein an " +
"Token und Origin-Liste. Wird der Port nicht erreicht, fehlt vermutlich die " +
$"Freigabe in der Windows-Firewall (eingehend, TCP {_config.Port}).");
}
}
/// <summary>
/// Die IPv4-Adressen dieses Rechners — nur fuer die Anzeige, damit im
/// Protokoll und in der Zwischenablage steht, was ein zweiter Rechner
/// tatsaechlich ansprechen kann.
/// </summary>
private static string[] LocalAddresses()
{
try
{
var addresses = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(u => u.Address)
.Where(a => a.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(a))
.Select(a => a.ToString())
.ToArray();
return addresses.Length > 0 ? addresses : ["127.0.0.1"];
}
catch (NetworkInformationException ex)
{
Log.Debug($"Eigene Adressen nicht ermittelbar: {ex.Message}");
return ["127.0.0.1"];
}
}
/// <summary>
/// Nur der IPv4-Listener des Hauptports ist Bedingung (<paramref name="required"/>);
/// alles Weitere — IPv6 und der Plugin-Port — ist Zugabe: faellt es aus,
/// laeuft der Zugang ueber die verbleibenden Listener weiter.
/// </summary>
private bool TryListen(IPAddress address, int port, bool required)
{
try
{
var listener = new TcpListener(address, port);
listener.Start();
_listeners.Add(listener);
_ = Task.Run(() => AcceptLoopAsync(listener));
return true;
}
catch (SocketException ex)
{
if (required)
{
Log.Error($"Port {port} konnte nicht belegt werden — WebSocket-Zugang bleibt aus. " +
$"Belegt ihn ein anderes Programm? Anderen Port in {WebSocketConfig.FilePath} eintragen.", ex);
}
else if (address.AddressFamily == AddressFamily.InterNetwork)
{
Log.Error($"Port {port} konnte nicht belegt werden — dieser Zugang bleibt aus, " +
"der uebrige WebSocket-Zugang laeuft weiter.", ex);
}
else
{
Log.Debug($"IPv6 auf Port {port} nicht verfuegbar ({ex.SocketErrorCode}); " +
"es wird nur ueber IPv4 bedient.");
}
return false;
}
}
private async Task AcceptLoopAsync(TcpListener listener)
{
while (!_shutdown.IsCancellationRequested)
{
try
{
var client = await listener.AcceptTcpClientAsync(_shutdown.Token).ConfigureAwait(false);
_ = Task.Run(() => HandleAsync(client));
}
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
{
return; // Beenden oder Listener geschlossen.
}
catch (Exception ex)
{
Log.Error("Fehler beim Annehmen einer WebSocket-Verbindung.", ex);
return;
}
}
}
private async Task HandleAsync(TcpClient client)
{
var remote = client.Client.RemoteEndPoint?.ToString() ?? "unbekannt";
// Am lokalen Port haengt die Rolle der Verbindung: der Plugin-Port
// traegt nur die Tab-Verwaltung, alles andere den vollen Dienst.
var isPlugin = _config.PluginPort > 0
&& client.Client.LocalEndPoint is IPEndPoint local
&& local.Port == _config.PluginPort;
using (client)
{
try
{
client.NoDelay = true;
var stream = client.GetStream();
using var handshake = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
handshake.CancelAfter(HandshakeTimeout);
var request = await WebSocketHandshake.ReadAsync(stream, handshake.Token).ConfigureAwait(false);
if (request is null)
{
Log.Debug($"Unvollstaendige Anfrage von {remote} verworfen.");
return;
}
if (_sessions.Count >= _config.MaxConnections)
{
await Reject(stream, 503, "Zu viele Verbindungen.", remote, handshake.Token).ConfigureAwait(false);
return;
}
if (WebSocketHandshake.Validate(request, _config) is { } refusal)
{
await Reject(stream, refusal.Status, refusal.Reason, remote, handshake.Token).ConfigureAwait(false);
return;
}
await WebSocketHandshake.AcceptAsync(stream, request, handshake.Token).ConfigureAwait(false);
await ServeAsync(stream, request, remote, isPlugin).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Beenden oder Zeitgrenze beim Handshake — nichts zu melden.
}
catch (Exception ex) when (ex is IOException or SocketException
or WebSocketException or ObjectDisposedException)
{
Log.Debug($"Verbindung {remote} abgebrochen: {ex.Message}");
}
catch (Exception ex)
{
Log.Error($"Fehler in der Verbindung {remote}.", ex);
}
}
}
private async Task ServeAsync(Stream stream, WebSocketHandshake.Request request, string remote, bool isPlugin)
{
// Ab hier uebernimmt die Klassenbibliothek: Rahmen, Maskierung,
// Ping/Pong und der Schliessvorgang.
var socket = WebSocket.CreateFromStream(stream, new WebSocketCreationOptions
{
IsServer = true,
SubProtocol = request.SubProtocol,
KeepAliveInterval = TimeSpan.FromSeconds(15)
});
var id = Interlocked.Increment(ref _nextSessionId);
using var session = new WebSocketSession(id, socket, _executor, _tabChannel, request.Origin, isPlugin);
_sessions[id] = session;
if (isPlugin)
{
_tabChannel.Register(session);
}
Log.Info($"WebSocket {id} verbunden ({remote}, Origin {session.Origin}" +
(isPlugin ? ", Plugin-Port)." : ")."));
try
{
await session.RunAsync(_shutdown.Token).ConfigureAwait(false);
}
finally
{
if (isPlugin)
{
_tabChannel.Unregister(session);
}
_sessions.TryRemove(id, out _);
Log.Info($"WebSocket {id} getrennt.");
}
}
private static async Task Reject(
Stream stream, int status, string reason, string remote, CancellationToken cancellationToken)
{
Log.Info($"WebSocket-Anfrage von {remote} abgewiesen ({status}): {reason}");
await WebSocketHandshake.RejectAsync(stream, status, reason, cancellationToken).ConfigureAwait(false);
}
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
/// <summary>
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
/// Zustand beim Eintreffen eines Ereignisses schon aktuell ist. Wird aus
/// dem UI-Thread aufgerufen: das Serialisieren geschieht einmal und sofort,
/// das Senden laeuft danach ohne den UI-Thread aufzuhalten.
/// </summary>
public void Broadcast(SwyxSnapshot previous, SwyxSnapshot current)
{
if (_sessions.IsEmpty)
{
return;
}
var messages = new List<string> { Protocol.Serialize(SnapshotMessage.From(current)) };
foreach (var callEvent in CallEventMessage.Diff(previous, current))
{
messages.Add(Protocol.Serialize(callEvent));
}
foreach (var session in _sessions.Values)
{
// Der Plugin-Port traegt nur die Tab-Verwaltung — Zustand und
// Anruf-Ereignisse gehen ausschliesslich an den Hauptport.
if (session.IsPlugin)
{
continue;
}
_ = session.TrySendManyAsync(messages, _shutdown.Token);
}
}
public void Dispose()
{
_shutdown.Cancel();
foreach (var listener in _listeners)
{
try
{
listener.Stop();
}
catch (SocketException)
{
// Beim Herunterfahren ohne Belang.
}
}
_listeners.Clear();
foreach (var session in _sessions.Values)
{
session.Dispose();
}
_sessions.Clear();
IsRunning = false;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Collections.Concurrent;
namespace SwyxTray.Web;
/// <summary>
/// Der Tab-Verwaltungskanal zum Firefox-Plugin: SwyxTray schickt Auftraege
/// (<see cref="TabRequestMessage"/>), das Plugin antwortet mit
/// <c>tabresult</c>. Mehr laeuft auf dem Plugin-Port nicht — Telefonie gibt
/// es nur auf dem Hauptport.
///
/// Die Richtung ist hier umgekehrt zum uebrigen Protokoll: der Server fragt,
/// der Client antwortet. Anfrage und Antwort werden ueber eine eigene,
/// serverseitig vergebene Id einander zugeordnet.
/// </summary>
internal sealed class PluginTabChannel
{
/// <summary>
/// So lange darf das Plugin brauchen. Danach faellt <c>focus</c> auf den
/// Browserstart zurueck, statt die Kommando-Antwort aufzuhalten.
/// </summary>
private static readonly TimeSpan AnswerTimeout = TimeSpan.FromSeconds(5);
private readonly ConcurrentDictionary<int, WebSocketSession> _plugins = new();
private readonly ConcurrentDictionary<int, TaskCompletionSource<ClientCommand>> _pending = new();
private int _nextRequestId;
public void Register(WebSocketSession session) => _plugins[session.Id] = session;
public void Unregister(WebSocketSession session) => _plugins.TryRemove(session.Id, out _);
/// <summary>
/// Die offenen Tabs, oder <c>null</c>, wenn kein Plugin verbunden ist,
/// keine Antwort kommt oder das Plugin den Auftrag ablehnt.
/// </summary>
public async Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
=> await RequestAsync(id => new TabRequestMessage(id, "list"), cancellationToken)
.ConfigureAwait(false) is { Ok: true } answer
? answer.Tabs ?? []
: null;
/// <summary>
/// Oeffnet einen neuen Tab mit der URL. Liefert die Id des neuen Tabs,
/// oder <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
/// </summary>
public async Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
=> await RequestAsync(id => new TabRequestMessage(id, "open", Url: url), cancellationToken)
.ConfigureAwait(false) is { Ok: true } answer
? answer.TabId ?? -1
: null;
/// <summary>Schliesst den Tab. <c>true</c> nur bei bestaetigtem Erfolg.</summary>
public async Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
=> (await RequestAsync(id => new TabRequestMessage(id, "close", TabId: tabId), cancellationToken)
.ConfigureAwait(false))?.Ok == true;
private async Task<ClientCommand?> RequestAsync(
Func<int, TabRequestMessage> request, CancellationToken cancellationToken)
{
// Bei mehreren Verbindungen (etwa nach einem Firefox-Neustart, dessen
// alte Session noch nicht ausgelaufen ist) ist die juengste die richtige.
var session = _plugins.Values.MaxBy(s => s.Id);
if (session is null)
{
return null;
}
var id = Interlocked.Increment(ref _nextRequestId);
var pending = new TaskCompletionSource<ClientCommand>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = pending;
try
{
await session.TrySendManyAsync(
[Protocol.Serialize(request(id))], cancellationToken).ConfigureAwait(false);
var finished = await Task.WhenAny(
pending.Task, Task.Delay(AnswerTimeout, cancellationToken)).ConfigureAwait(false);
if (finished != pending.Task)
{
Log.Info($"Tab-Anfrage {id} blieb ohne Antwort vom Firefox-Plugin.");
return null;
}
return await pending.Task.ConfigureAwait(false);
}
finally
{
_pending.TryRemove(id, out _);
}
}
/// <summary>Ordnet ein <c>tabresult</c> des Plugins der wartenden Anfrage zu.</summary>
public bool TryComplete(ClientCommand tabResult)
=> tabResult.Id is { } id
&& _pending.TryRemove(id, out var pending)
&& pending.TrySetResult(tabResult);
}
+209
View File
@@ -0,0 +1,209 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SwyxTray.Swyx;
namespace SwyxTray.Web;
/// <summary>
/// Die ueber den WebSocket ausgetauschten Nachrichten. Alles ist UTF-8-JSON,
/// eine Nachricht je WebSocket-Frame.
///
/// Leitungen werden nach aussen 1-basiert gezaehlt ("Leitung 1"), so wie sie im
/// Kontextmenue und in SwyxIt! erscheinen — intern sind sie 0-basiert.
/// </summary>
internal static class Protocol
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static string Serialize<T>(T message) => JsonSerializer.Serialize(message, Json);
}
/// <summary>Nachricht der Webseite an die App.</summary>
internal sealed class ClientCommand
{
/// <summary>Frei waehlbar; wird in der Antwort zurueckgegeben.</summary>
public int? Id { get; set; }
public string? Cmd { get; set; }
/// <summary>Nur bei <c>call</c>.</summary>
public string? Number { get; set; }
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
public int? Line { get; set; }
/// <summary>Nur bei <c>focus</c>: gesuchter Fenstertitel.</summary>
public string? Title { get; set; }
/// <summary>
/// Bei <c>focus</c> und <c>opentab</c>: die zu oeffnende Adresse.
/// </summary>
public string? Url { get; set; }
/// <summary>Nur bei <c>tabresult</c> (Plugin-Port): Auftrag ausgefuehrt?</summary>
public bool? Ok { get; set; }
/// <summary>
/// Bei <c>closetab</c> (Hauptport): der zu schliessende Tab. Bei
/// <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen Tabs.
/// </summary>
public int? TabId { get; set; }
/// <summary>Nur bei <c>tabresult</c> auf <c>list</c>: die offenen Tabs.</summary>
public List<TabInfo>? Tabs { get; set; }
}
/// <summary>Ein offener Tab, wie ihn das Plugin bei <c>list</c> meldet.</summary>
internal sealed class TabInfo
{
public int? Id { get; set; }
public string? Title { get; set; }
public string? Url { get; set; }
public bool? Active { get; set; }
}
/// <summary>Begruessung unmittelbar nach dem Verbindungsaufbau.</summary>
internal sealed record HelloMessage(string App, string Version, int Protocol, int Session)
{
public string Type => "hello";
}
/// <summary>
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>.
/// </summary>
internal sealed record ResultMessage(
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null)
{
public string Type => "result";
}
/// <summary>
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen:
/// <c>list</c> (offene Tabs melden), <c>open</c> (neuen Tab mit <c>Url</c>
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen). Das Plugin antwortet
/// jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei <c>list</c>
/// zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>. Die Id
/// stammt hier vom Server, nicht von einer Webseite.
/// </summary>
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null)
{
public string Type => "tab";
}
internal sealed record LineMessage(
int Line,
string State,
int StateCode,
string StateText,
string Peer,
string PeerNumber,
string PeerName,
bool Busy,
bool Selected);
/// <summary>
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
/// Zustaende. Wird zusaetzlich zum snapshot gesendet, damit eine Seite auf
/// eingehende Rufe reagieren kann, ohne selbst Zustaende zu vergleichen.
///
/// <c>Event</c> ist <c>incoming</c> (Leitung beginnt zu klingeln),
/// <c>outgoing</c> (Wahl beginnt), <c>connected</c> (Gespraech steht) oder
/// <c>ended</c> (Leitung wieder frei). Bei <c>connected</c> steht in
/// <c>Direction</c>, ob der Ruf ein- oder ausgehend war, sofern erkennbar;
/// bei <c>ended</c> stammen die Peer-Angaben aus dem letzten belegten Zustand.
/// </summary>
internal sealed record CallEventMessage(
string Event,
int Line,
string Peer,
string PeerNumber,
string PeerName,
string? Direction = null)
{
public string Type => "call";
public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current)
{
var events = new List<CallEventMessage>();
var before = previous.Lines.ToDictionary(l => l.Index);
foreach (var line in current.Lines)
{
before.TryGetValue(line.Index, out var prev);
var wasRinging = prev?.State.IsRinging() ?? false;
var wasDialing = prev?.State.IsDialing() ?? false;
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold());
if (line.State.IsRinging() && !wasRinging)
{
events.Add(From("incoming", line));
}
else if (line.State.IsDialing() && !wasDialing && !wasRinging && !wasTalking)
{
events.Add(From("outgoing", line));
}
else if (line.State.IsActive() && !wasTalking)
{
// Halten und Zurueckholen ist kein neues Gespraech; der Wechsel
// aus Klingeln oder Wahl dagegen schon.
events.Add(From("connected", line,
wasRinging ? "incoming" : wasDialing ? "outgoing" : null));
}
}
var after = current.Lines.ToDictionary(l => l.Index);
foreach (var prev in previous.Lines.Where(l => l.IsBusy))
{
if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy)
{
events.Add(From("ended", prev));
}
}
return events;
}
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null)
=> new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction);
}
/// <summary>
/// Vollstaendiger Zustand. Wird nach dem hello, bei jeder Aenderung und auf
/// <c>status</c> gesendet — die Webseite muss also nie einen Zustand mitfuehren.
/// </summary>
internal sealed record SnapshotMessage(
bool Connected,
bool ServerUp,
string Overall,
string StatusText,
string User,
string Server,
IReadOnlyList<LineMessage> Lines)
{
public string Type => "snapshot";
public static SnapshotMessage From(SwyxSnapshot snapshot) => new(
Connected: snapshot.IsConnected,
ServerUp: snapshot.IsServerUp,
Overall: snapshot.Overall.ToString(),
StatusText: snapshot.StatusText,
User: snapshot.UserName,
Server: snapshot.ServerName,
Lines: snapshot.Lines.Select(l => new LineMessage(
Line: l.Index + 1,
State: l.State.ToString(),
StateCode: (int)l.State,
StateText: l.State.ToDisplayText(),
Peer: l.PeerDisplayLong,
PeerNumber: l.PeerNumber,
PeerName: l.PeerName,
Busy: l.IsBusy,
Selected: l.IsSelected)).ToList());
}
+48
View File
@@ -0,0 +1,48 @@
namespace SwyxTray.Web;
/// <summary>
/// Schleust Arbeit aus den Netzwerk-Threads in den UI-Thread.
///
/// Notwendig, weil CLMgr ein STA-COM-Server ist und die Anwendung alle
/// COM-Zugriffe im WinForms-Thread buendelt (siehe SwyxClient). Ein Aufruf aus
/// einem Thread-Pool-Thread wuerde COM zwingen, den Aufruf selbst zu
/// marshallen — mit einer eigenen, hier nicht vorhandenen Nachrichtenschleife.
///
/// Der SynchronizationContext wird bewusst erst innerhalb der laufenden
/// Nachrichtenschleife eingesammelt; vorher steht der WinForms-Kontext noch
/// nicht.
/// </summary>
internal sealed class UiDispatcher
{
private readonly SynchronizationContext _context;
private UiDispatcher(SynchronizationContext context) => _context = context;
/// <summary>Liefert <c>null</c>, wenn kein UI-Kontext installiert ist.</summary>
public static UiDispatcher? Capture()
=> SynchronizationContext.Current is { } context ? new UiDispatcher(context) : null;
public async Task<T> RunAsync<T>(Func<T> function, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
// Ohne die Registrierung bliebe der Aufrufer haengen, falls die
// Nachrichtenschleife beim Beenden nicht mehr zum Zug kommt.
await using var registration = cancellationToken.Register(
() => completion.TrySetCanceled(cancellationToken));
_context.Post(_ =>
{
try
{
completion.TrySetResult(function());
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
}, null);
return await completion.Task.ConfigureAwait(false);
}
}
+218
View File
@@ -0,0 +1,218 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SwyxTray.Web;
/// <summary>
/// Einstellungen des WebSocket-Zugangs, abgelegt neben dem Protokoll unter
/// %LOCALAPPDATA%\SwyxTray\websocket.json.
///
/// Beim ersten Start wird die Datei mit einem zufaelligen Token erzeugt. Die
/// Liste der erlaubten Origins bleibt dabei leer — ohne Eintrag kann sich
/// keine Webseite verbinden. Das ist Absicht: ein offener Port, an dem jede
/// beliebige Seite Anrufe ausloesen kann, waere die schlechtere Voreinstellung.
/// </summary>
internal sealed class WebSocketConfig
{
/// <summary>
/// Version des JSON-Protokolls, wird im hello-Paket gemeldet.
/// Version 2: zusaetzliche <c>call</c>-Ereignisse (incoming/outgoing/
/// connected/ended) nach jedem snapshot.
/// Version 3: Kommando <c>focus</c> (Fenster in den Vordergrund holen,
/// ersatzweise URL im Standardbrowser oeffnen).
/// Version 4: Plugin-Port als reiner Tab-Verwaltungskanal
/// (<c>tab</c>-Auftraege an das Firefox-Plugin, <c>tabresult</c> zurueck).
/// Version 5: der eine Tab-Auftrag ist in die drei Aktionen <c>list</c>,
/// <c>open</c> und <c>close</c> aufgeteilt.
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
/// </summary>
public const int ProtocolVersion = 6;
/// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
/// in JEDEM Build, nicht nur im Debug-Build. Wer den Port erreicht, kann
/// telefonieren und mitlesen; zusammen mit
/// <see cref="AllowRemoteAccess"/> ist das jeder im selben Netz.
///
/// Das ist eine bewusste Entscheidung fuer die Erprobung. Zum
/// Wiedereinschalten die folgende Zuweisung durch
///
/// public static readonly bool SecurityDisabled =
/// #if DEBUG
/// true;
/// #else
/// false;
/// #endif
///
/// ersetzen — dann prueft der Release-Build wieder alles, waehrend sich der
/// Debug-Build ohne Einrichtung erproben laesst. Die dazugehoerigen
/// Pruefungen stehen unveraendert in
/// <see cref="WebSocketHandshake.Validate"/>, es ist also nur diese eine
/// Zeile.
///
/// Kein <c>const</c>, sonst meldet der Compiler die abhaengigen Zweige als
/// unerreichbar.
/// </summary>
public static readonly bool SecurityDisabled = true;
public static string FilePath { get; } = Path.Combine(Log.Directory, "websocket.json");
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
public bool Enabled { get; set; } = true;
public int Port { get; set; } = 17654;
/// <summary>
/// Zweiter Port, auf dem derselbe Dienst zusaetzlich hoert — gedacht fuer
/// das Firefox-Plugin (SwyxFFPlugin), damit Plugin und normale Seiten
/// sich nicht denselben Eintrag teilen muessen. 0 schaltet ihn ab.
/// </summary>
public int PluginPort { get; set; } = 17655;
/// <summary>Gemeinsames Geheimnis. Leer = Zugang gesperrt.</summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// Erlaubte Werte des Origin-Headers, z. B. "https://crm.example.local".
/// Ein Eintrag "*" laesst jede Seite zu und wird beim Start bemaengelt.
/// </summary>
public List<string> AllowedOrigins { get; set; } = new();
public int MaxConnections { get; set; } = 8;
/// <summary>
/// Voreingestellt hoert die App nur auf 127.0.0.1 und ::1. Auf <c>true</c>
/// bindet sie an alle Schnittstellen und ist damit aus dem lokalen Netz
/// erreichbar — gedacht fuer den Test von einem zweiten Rechner aus.
///
/// Damit faellt die Host-Pruefung weg (die App kann nicht wissen, unter
/// welchem Namen sie angesprochen wird), und der Zugang haengt allein an
/// Token und Origin-Liste. Im Debug-Build, in dem auch die nicht geprueft
/// werden, kann dann jeder im selben Netz Anrufe ausloesen.
/// </summary>
public bool AllowRemoteAccess { get; set; }
/// <summary>
/// Laedt die Konfiguration; legt sie beim ersten Start an. Bei einer
/// defekten Datei wird bewusst <c>null</c> geliefert und der Server nicht
/// gestartet: ein neu erzeugtes Token wuerde alle Clients aussperren, ohne
/// dass jemand die Ursache sieht.
/// </summary>
public static WebSocketConfig? Load()
{
try
{
if (!File.Exists(FilePath))
{
var created = new WebSocketConfig { Token = NewToken() };
Directory.CreateDirectory(Log.Directory);
File.WriteAllText(FilePath, JsonSerializer.Serialize(created, WriteOptions), Encoding.UTF8);
Log.Info($"WebSocket-Konfiguration angelegt: {FilePath}. Es ist noch kein Origin " +
"eingetragen, deshalb wird jede Verbindung aus dem Browser abgewiesen.");
return created;
}
var config = JsonSerializer.Deserialize<WebSocketConfig>(File.ReadAllText(FilePath), ReadOptions);
if (config is null)
{
Log.Error($"WebSocket-Konfiguration {FilePath} ist leer — Server wird nicht gestartet.");
return null;
}
return config.Validate() ? config : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Error($"WebSocket-Konfiguration {FilePath} konnte nicht gelesen werden — " +
"Server wird nicht gestartet.", ex);
return null;
}
}
private bool Validate()
{
if (Port is < 1 or > 65535)
{
Log.Error($"Ungueltiger Port {Port} in {FilePath} — Server wird nicht gestartet.");
return false;
}
if (PluginPort != 0 && (PluginPort is < 1 or > 65535 || PluginPort == Port))
{
Log.Error($"Ungueltiger PluginPort {PluginPort} in {FilePath} — " +
"der Plugin-Port bleibt aus, der uebrige Zugang startet normal.");
PluginPort = 0;
}
if (MaxConnections < 1)
{
MaxConnections = 1;
}
// Solange die Pruefung abgeschaltet ist, darf ein fehlendes oder kurzes
// Token den Start nicht verhindern.
if (Token.Length < 16 && !SecurityDisabled)
{
Log.Error("Das Token in der WebSocket-Konfiguration fehlt oder ist zu kurz " +
"(mindestens 16 Zeichen) — Server wird nicht gestartet.");
return false;
}
if (SecurityDisabled)
{
return true;
}
if (AllowedOrigins.Any(o => o.Trim() == "*"))
{
Log.Error("In der WebSocket-Konfiguration steht '*' als erlaubter Origin. Damit kann " +
"jede im Browser geoeffnete Seite Anrufe ausloesen — nur zum Erproben nutzen.");
}
else if (AllowedOrigins.Count == 0)
{
Log.Info("Es ist kein Origin eingetragen; Verbindungen aus dem Browser werden " +
$"abgewiesen. Erlaubte Origins in {FilePath} eintragen.");
}
return true;
}
/// <summary>
/// Prueft den Origin des Handshakes. Gross-/Kleinschreibung und ein
/// abschliessender Schraegstrich werden ignoriert, weil Browser den Header
/// ohne Pfad senden, Konfigurationen ihn aber oft mit Schraegstrich enthalten.
/// </summary>
public bool IsOriginAllowed(string origin)
=> AllowedOrigins.Any(allowed =>
{
var trimmed = allowed.Trim();
return trimmed == "*"
|| string.Equals(trimmed.TrimEnd('/'), origin.TrimEnd('/'), StringComparison.OrdinalIgnoreCase);
});
/// <summary>Zeitkonstanter Vergleich — das Token ist ein Geheimnis.</summary>
public bool IsTokenValid(string? candidate)
{
if (string.IsNullOrEmpty(candidate) || Token.Length == 0)
{
return false;
}
var expected = Encoding.UTF8.GetBytes(Token);
var actual = Encoding.UTF8.GetBytes(candidate);
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
}
+261
View File
@@ -0,0 +1,261 @@
using System.Security.Cryptography;
using System.Text;
namespace SwyxTray.Web;
/// <summary>
/// Der HTTP-Teil des WebSocket-Aufbaus (RFC 6455, Abschnitt 4.2). Das ist der
/// einzige Teil des Protokolls, der von Hand entsteht — Rahmen, Maskierung,
/// Ping/Pong und der Schliessvorgang kommen danach von
/// <see cref="System.Net.WebSockets.WebSocket.CreateFromStream"/>.
/// </summary>
internal static class WebSocketHandshake
{
/// <summary>Feste GUID aus RFC 6455 zur Bildung des Accept-Werts.</summary>
private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const int MaxHeaderBytes = 16 * 1024;
public const string SubProtocol = "swyxtray.v1";
/// <summary>Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding.</summary>
private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"];
internal sealed record Request(
string Method,
string Target,
IReadOnlyDictionary<string, string> Headers,
string? Token,
string Origin,
string? SubProtocol);
/// <summary>
/// Liest die Handshake-Anfrage. Bewusst byteweise bis zur Leerzeile: alles
/// dahinter gehoert bereits dem WebSocket, und ein zu grosszuegiges Lesen
/// wuerde die ersten Frames verschlucken.
/// </summary>
public static async Task<Request?> ReadAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[1];
var raw = new List<byte>(1024);
while (raw.Count < MaxHeaderBytes)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
return null;
}
raw.Add(buffer[0]);
if (raw.Count >= 4 &&
raw[^4] == (byte)'\r' && raw[^3] == (byte)'\n' &&
raw[^2] == (byte)'\r' && raw[^1] == (byte)'\n')
{
return Parse(Encoding.ASCII.GetString(raw.ToArray()));
}
}
return null;
}
private static Request? Parse(string text)
{
var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0)
{
return null;
}
var requestLine = lines[0].Split(' ');
if (requestLine.Length < 3)
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Skip(1))
{
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
var name = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
// Mehrfach gesendete Header zusammenfassen, wie in HTTP vorgesehen.
headers[name] = headers.TryGetValue(name, out var existing)
? $"{existing}, {value}"
: value;
}
var offered = headers.GetValueOrDefault("Sec-WebSocket-Protocol", string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new Request(
Method: requestLine[0],
Target: requestLine[1],
Headers: headers,
Token: TokenFrom(requestLine[1], offered),
Origin: headers.GetValueOrDefault("Origin", string.Empty),
SubProtocol: offered.Contains(SubProtocol) ? SubProtocol : null);
}
/// <summary>
/// Das Token darf in der Abfragezeichenfolge (<c>?token=…</c>) oder als
/// Unterprotokoll <c>token.…</c> kommen. Die zweite Form ist fuer Seiten
/// nuetzlich, die das Geheimnis nicht in einer URL stehen haben wollen —
/// die WebSocket-API des Browsers kennt keine eigenen Header.
/// </summary>
private static string? TokenFrom(string target, IEnumerable<string> offeredProtocols)
{
var fromProtocol = offeredProtocols
.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal))?["token.".Length..];
if (!string.IsNullOrEmpty(fromProtocol))
{
return fromProtocol;
}
var query = target.IndexOf('?');
if (query < 0)
{
return null;
}
foreach (var pair in target[(query + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var equals = pair.IndexOf('=');
if (equals > 0 && pair[..equals].Equals("token", StringComparison.OrdinalIgnoreCase))
{
return Uri.UnescapeDataString(pair[(equals + 1)..]);
}
}
return null;
}
/// <summary>
/// Prueft die Anfrage. Liefert <c>null</c>, wenn alles passt, sonst
/// HTTP-Status und Begruendung fuer die Absage.
/// </summary>
public static (int Status, string Reason)? Validate(Request request, WebSocketConfig config)
{
if (!request.Method.Equals("GET", StringComparison.Ordinal))
{
return (405, "Nur GET.");
}
if (!Contains(request.Headers.GetValueOrDefault("Upgrade"), "websocket") ||
!Contains(request.Headers.GetValueOrDefault("Connection"), "upgrade"))
{
return (400, "Kein WebSocket-Upgrade.");
}
if (request.Headers.GetValueOrDefault("Sec-WebSocket-Version") != "13")
{
return (426, "Es wird nur Sec-WebSocket-Version 13 unterstuetzt.");
}
if (string.IsNullOrEmpty(request.Headers.GetValueOrDefault("Sec-WebSocket-Key")))
{
return (400, "Sec-WebSocket-Key fehlt.");
}
// Bis hierher ging es nur um ein wohlgeformtes Upgrade; alles Weitere
// sind die Zugriffsschranken — und die sind zur Zeit abgeschaltet,
// siehe WebSocketConfig.SecurityDisabled.
if (WebSocketConfig.SecurityDisabled)
{
return null;
}
// Ohne diese Pruefung koennte eine Seite einen eigenen Namen auf
// 127.0.0.1 zeigen lassen und so aus ihrem Ursprung heraus verbinden.
// Bei Zugriff aus dem Netz entfaellt sie: welchen Namen oder welche
// Adresse ein entfernter Rechner verwendet, kann die App nicht wissen.
if (!config.AllowRemoteAccess)
{
var raw = request.Headers.GetValueOrDefault("Host", string.Empty);
var host = raw.StartsWith('[') ? "[::1]" : raw.Split(':')[0];
if (!LoopbackHosts.Contains(host, StringComparer.OrdinalIgnoreCase))
{
return (400, "Unerwarteter Host — erlaubt ist nur 127.0.0.1 bzw. localhost.");
}
}
if (!config.IsTokenValid(request.Token))
{
return (403, "Token fehlt oder ist falsch.");
}
// Fehlt der Origin, ist der Aufrufer kein Browser. Das ist zulaessig,
// weil das Token bereits nachgewiesen wurde; eine Webseite kann den
// Header dagegen nicht weglassen, ihr Ursprung wird also stets geprueft.
if (request.Origin.Length > 0 && !config.IsOriginAllowed(request.Origin))
{
return (403, $"Origin '{request.Origin}' ist nicht freigegeben.");
}
return null;
}
public static Task AcceptAsync(Stream stream, Request request, CancellationToken cancellationToken)
{
var key = request.Headers["Sec-WebSocket-Key"];
var accept = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + AcceptGuid)));
var response = new StringBuilder()
.Append("HTTP/1.1 101 Switching Protocols\r\n")
.Append("Upgrade: websocket\r\n")
.Append("Connection: Upgrade\r\n")
.Append($"Sec-WebSocket-Accept: {accept}\r\n");
if (request.SubProtocol is not null)
{
response.Append($"Sec-WebSocket-Protocol: {request.SubProtocol}\r\n");
}
return WriteAsync(stream, response.Append("\r\n").ToString(), cancellationToken);
}
/// <summary>
/// Absage mit Klartext im Rumpf. Der Browser zeigt davon zwar nur den
/// Status, aber im Protokoll der App und in einem Netzwerkmitschnitt steht
/// damit der Grund — sonst bleibt nur ein wortloses Scheitern.
/// </summary>
public static Task RejectAsync(Stream stream, int status, string reason, CancellationToken cancellationToken)
{
var body = Encoding.UTF8.GetBytes(reason);
var head =
$"HTTP/1.1 {status} {StatusText(status)}\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
$"Content-Length: {body.Length}\r\n" +
"Connection: close\r\n\r\n";
return WriteAsync(stream, head + reason, cancellationToken);
}
private static async Task WriteAsync(Stream stream, string text, CancellationToken cancellationToken)
{
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static bool Contains(string? headerValue, string token)
=> headerValue is not null && headerValue.Contains(token, StringComparison.OrdinalIgnoreCase);
private static string StatusText(int status) => status switch
{
400 => "Bad Request",
403 => "Forbidden",
405 => "Method Not Allowed",
426 => "Upgrade Required",
503 => "Service Unavailable",
_ => "Error"
};
}
+247
View File
@@ -0,0 +1,247 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
namespace SwyxTray.Web;
/// <summary>
/// Eine Verbindung. Empfaengt Kommandos, beantwortet sie und nimmt vom Server
/// die Zustandsmeldungen entgegen.
///
/// Kommandos werden streng nacheinander abgearbeitet. Das kostet nichts —
/// Anrufe kommen nicht im Buendel — und haelt die Zugriffe auf den COM-Server
/// in der Reihenfolge, in der die Webseite sie gesendet hat.
/// </summary>
internal sealed class WebSocketSession : IDisposable
{
private const int MaxMessageBytes = 64 * 1024;
private readonly WebSocket _socket;
private readonly CommandExecutor _executor;
private readonly PluginTabChannel _tabs;
private readonly SemaphoreSlim _sendGate = new(1, 1);
public int Id { get; }
public string Origin { get; }
/// <summary>
/// Verbindung ueber den Plugin-Port: darauf gibt es nur die
/// Tab-Verwaltung — keine Zustandsmeldungen, keine Telefonie-Kommandos.
/// </summary>
public bool IsPlugin { get; }
public WebSocketSession(
int id, WebSocket socket, CommandExecutor executor, PluginTabChannel tabs,
string origin, bool isPlugin)
{
Id = id;
_socket = socket;
_executor = executor;
_tabs = tabs;
Origin = origin.Length > 0 ? origin : "(ohne Origin)";
IsPlugin = isPlugin;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
await SendAsync(Protocol.Serialize(new HelloMessage(
App: "SwyxTray",
Version: typeof(WebSocketSession).Assembly.GetName().Version?.ToString() ?? "1.0",
Protocol: WebSocketConfig.ProtocolVersion,
Session: Id)), cancellationToken).ConfigureAwait(false);
if (!IsPlugin)
{
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
}
var buffer = new byte[8 * 1024];
var message = new MemoryStream();
while (_socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await _socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, cancellationToken)
.ConfigureAwait(false);
return;
}
if (result.MessageType == WebSocketMessageType.Binary)
{
await CloseAsync(WebSocketCloseStatus.InvalidMessageType, "Nur Text (JSON).", cancellationToken)
.ConfigureAwait(false);
return;
}
message.Write(buffer, 0, result.Count);
if (message.Length > MaxMessageBytes)
{
await CloseAsync(WebSocketCloseStatus.MessageTooBig, "Nachricht zu gross.", cancellationToken)
.ConfigureAwait(false);
return;
}
if (!result.EndOfMessage)
{
continue;
}
var text = Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length);
message.SetLength(0);
await HandleAsync(text, cancellationToken).ConfigureAwait(false);
}
}
private async Task HandleAsync(string text, CancellationToken cancellationToken)
{
ClientCommand? command;
try
{
command = JsonSerializer.Deserialize<ClientCommand>(text, Protocol.Json);
}
catch (JsonException ex)
{
Log.Debug($"WebSocket {Id}: ungueltiges JSON — {ex.Message}");
await SendAsync(Protocol.Serialize(new ResultMessage(null, false, "Ungueltiges JSON.")),
cancellationToken).ConfigureAwait(false);
return;
}
if (command is null || string.IsNullOrWhiteSpace(command.Cmd))
{
await SendAsync(Protocol.Serialize(new ResultMessage(command?.Id, false, "Feld 'cmd' fehlt.")),
cancellationToken).ConfigureAwait(false);
return;
}
Log.Debug($"WebSocket {Id}: Kommando '{command.Cmd}' (id={command.Id}).");
if (IsPlugin)
{
await HandlePluginAsync(command, cancellationToken).ConfigureAwait(false);
return;
}
// 'status' beantwortet der Server mit dem vollen Zustand; die Quittung
// danach haelt den Ablauf fuer den Client einheitlich.
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
{
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
.ConfigureAwait(false);
return;
}
var response = await _executor.ExecuteAsync(command, cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf
/// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich
/// ueber den Hauptport.
/// </summary>
private async Task HandlePluginAsync(ClientCommand command, CancellationToken cancellationToken)
{
switch (command.Cmd!.Trim().ToLowerInvariant())
{
case "tabresult":
// Die Antwort auf einen Tab-Auftrag — sie wird der wartenden
// Anfrage zugeordnet und selbst nicht quittiert.
if (!_tabs.TryComplete(command))
{
Log.Debug($"WebSocket {Id}: tabresult {command.Id} ohne wartende Anfrage.");
}
return;
case "ping":
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
.ConfigureAwait(false);
return;
default:
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, false,
"Auf dem Plugin-Port gibt es nur die Tab-Verwaltung (tabresult, ping).")),
cancellationToken).ConfigureAwait(false);
return;
}
}
private async Task SendSnapshotAsync(CancellationToken cancellationToken)
{
var snapshot = await _executor.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(SnapshotMessage.From(snapshot)), cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen.
/// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem
/// Empfangs-Thread kommen, ist die Sperre hier nicht optional.
/// </summary>
public Task SendAsync(string json, CancellationToken cancellationToken)
=> SendManyAsync([json], cancellationToken);
/// <summary>
/// Sendet mehrere Nachrichten am Stueck, ohne dass sich etwas dazwischen
/// schieben kann — so kommen snapshot und die dazugehoerigen
/// Anruf-Ereignisse immer in der erzeugten Reihenfolge an.
/// </summary>
public async Task SendManyAsync(IReadOnlyList<string> jsons, CancellationToken cancellationToken)
{
await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
foreach (var json in jsons)
{
if (_socket.State != WebSocketState.Open)
{
return;
}
await _socket.SendAsync(
Encoding.UTF8.GetBytes(json),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken).ConfigureAwait(false);
}
}
finally
{
_sendGate.Release();
}
}
/// <summary>Fuer das Verteilen an alle: ein toter Client darf nicht stoeren.</summary>
public async Task TrySendManyAsync(IReadOnlyList<string> jsons, CancellationToken cancellationToken)
{
try
{
await SendManyAsync(jsons, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException
or OperationCanceledException or IOException)
{
Log.Debug($"WebSocket {Id}: Senden fehlgeschlagen ({ex.GetType().Name}).");
}
}
private async Task CloseAsync(WebSocketCloseStatus status, string reason, CancellationToken cancellationToken)
{
Log.Debug($"WebSocket {Id} wird geschlossen: {reason}");
await _socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
_socket.Dispose();
_sendGate.Dispose();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SwyxTray.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- Bewusst asInvoker: die App muss in derselben Sitzung und mit
demselben Integritaetslevel wie SwyxIt! laufen, sonst laesst
COM keine Verbindung zum laufenden Client zu. -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>