SwyxTray und Firefox-Plugin in das Repository aufnehmen
Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang, Firefox-Erweiterung und Browser-Beispielclient.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user