Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd355c4e21 | ||
|
|
78b17a1350 |
@@ -36,3 +36,8 @@ yarn-error.log*
|
|||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
core/vite.generated.ts
|
core/vite.generated.ts
|
||||||
core/src/main/frontend/generated/
|
core/src/main/frontend/generated/
|
||||||
|
|
||||||
|
# --- .NET (SwyxTray) ---
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
|||||||
Vendored
+37
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
// Startkonfigurationen aller Teilprojekte: Java-Core, Flutter-App und
|
||||||
|
// SwyxTray. Die SwyxTray-App hat kein Fenster: sichtbar ist nur das Symbol
|
||||||
|
// im Infobereich der Taskleiste, alles Weitere steht im Protokoll unter
|
||||||
|
// %LOCALAPPDATA%\SwyxTray\swyxtray.log.
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -52,6 +56,39 @@
|
|||||||
"program": "${workspaceFolder}/app/lib/main.dart",
|
"program": "${workspaceFolder}/app/lib/main.dart",
|
||||||
"cwd": "${workspaceFolder}/app",
|
"cwd": "${workspaceFolder}/app",
|
||||||
"flutterMode": "release"
|
"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