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