# 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*, *Neu verbinden*, *Protokoll öffnen*, *Beenden*. Doppelklick zeigt den Status als Sprechblase. ## Verwendetes SDK Offizielles NuGet-Paket von Enreach: ```xml ``` Das Paket enthält die Interop-Wrapper der ClientLineManager-COM-API (`IpPbx.CLMgrLib`) und wählt über `$(Platform)` automatisch `Interop.CLMgr.dll` (x86) bzw. `Interop64.CLMgr.dll` (x64). **Deshalb muss `Platform` gesetzt sein — bei `AnyCPU` bricht der Build mit einem Fehler des Pakets ab.** Die Version 14.21.0 passt zur hier installierten SwyxIt!-Version 14.21.4345.0. Bei einem Client-Update die Paketversion mitziehen. Genutzte Schnittstellen: - `IClientLineMgrDisp` — `DispInit`, `DispRegisterUser`, `DispNumberOfLines`, `DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`, `DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser`, `FulltextSearchInContactsEx`, `DispResolveNumber`, `DispClientConfig` - `IClientLineDisp` — `DispState`, `DispPeerNumber`, `DispPeerName` - `IClientConfig` — `PbxPhoneBookEnumerator`, `UserPhoneBookEnumerator` (globales und persönliches Telefonbuch für den Adress-Cache) - `INameNumberSearchResult(Collection)` — Ergebnistyp der Kontaktsuche. Die Telefonbuch-Sammlungen und ihre Einträge kommen dagegen **nicht** als `IDispCollection`/`IPbxPhoneBookEntryDisp` an, sondern als reine IDispatch-Objekte — sie werden per `IEnumerable` und Late-Binding gelesen (siehe „Stand der Prüfung") - `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 | | [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 (SwyxFFPlugin) wird als **eigenes Projekt** gepflegt und ist nicht Teil dieses Repositories. Sein `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 Plugin-Projekts wählen. Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der Origin des Plugins stehen. Firefox sendet dafür `moz-extension://`, wobei die UUID je Installation zufällig vergeben wird — sie steht in `about:debugging` unter „Interne UUID“ der Erweiterung. ### Kommandos (Seite → App) | Kommando | Felder | Wirkung | |---|---|---| | `call` | `number` | wählt auf einer freien Leitung | | `answer` | `line` (optional) | nimmt an; ohne Angabe die klingelnde Leitung | | `hangup` | `line` (optional) | legt auf; ohne Angabe die belegte Leitung | | `hold` | `line` (optional) | stellt das Gespräch in die Warteschleife | | `focus` | `url`, `title` (optional) | aktiviert oder öffnet den passenden Firefox-Tab über das Plugin | | `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus | | `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab | | `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id | | `addresses` | — | liefert die zwischengespeicherten Adressdaten (Telefonbücher), Antwort im Feld `addresses` | | `contacts` | `query` | durchsucht die Adressdaten des Swyx-Clients (Telefonbücher und Kontakt-Plugins), Antwort im Feld `contacts` | | `resolve` | `number` | löst eine Rufnummer über die Adressdaten in einen Namen auf, Antwort im Feld `name` (leer = unbekannt) | | `status` | — | fordert den vollen Zustand samt der zwischengespeicherten Adressdaten 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`, ein `snapshot` und die zwischengespeicherten Adressdaten als `addresses`, 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. **Adressdaten** (seit Protokollversion 8): Die App liest beim Verbindungsaufbau zu SwyxIt! — also beim Start und bei jeder Neuverbindung — sowie danach **alle 60 Minuten** das globale und das persönliche Telefonbuch aus dem Swyx-Client und hält sie im Speicher vor. Jede verbundene Seite bekommt den Stand als `addresses`-Nachricht zugestellt: direkt nach dem `snapshot` beim Verbinden, auf ein `status`-Kommando und unaufgefordert **nach jedem Neueinlesen**. Wer den Cache selbst abfragen will, sendet das Kommando `addresses` und erhält die Einträge im gleichnamigen Feld der Antwort. Bei einem Lesefehler behält der Cache den letzten erfolgreichen Stand. ```json {"type":"addresses","addresses":[ {"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}, {"name":"Schmidt, Anna","number":"123","description":"Persoenliches Telefonbuch"}]} ``` Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände vergleichen, um einen eingehenden Ruf zu erkennen: | `event` | Bedeutung | |---|---| | `incoming` | Leitung beginnt zu klingeln (auch Anklopfen) | | `outgoing` | Wahl eines ausgehenden Rufs beginnt | | `connected` | Gespräch steht; `direction` sagt `incoming`/`outgoing`, sofern erkennbar | | `ended` | Leitung ist wieder frei; Peer-Angaben aus dem letzten belegten Zustand | ```json {"type":"call","event":"incoming","line":1, "peer":"Muster GmbH (+493012345)","peerNumber":"+493012345","peerName":"Muster GmbH"} ``` Die Reihenfolge ist garantiert: erst der `snapshot`, unmittelbar danach die daraus abgeleiteten `call`-Ereignisse. Ein typischer Ablauf für ein angenommenes Gespräch ist also `incoming` → `connected` (mit `"direction":"incoming"`) → `ended`, jeweils mit einem `snapshot` davor. Wählt die Seite selbst per Kommando `call`, folgen `outgoing` → `connected` → `ended` auf demselben Weg. Diese Ereignisse gibt es seit Protokollversion 2 (Feld `protocol` im `hello`). ### Was den Zugang absichert > **Zur Zeit greift davon nur die Bindung.** Token-, Origin- und Host-Prüfung > sind für die Erprobung abgeschaltet, und `AllowRemoteAccess` steht auf `true` > — siehe die beiden folgenden Abschnitte. Der Rest dieses Abschnitts > beschreibt den eingeschalteten Zustand. Vier Schranken, die zusammen wirken: 1. **Gebunden nur an 127.0.0.1 und ::1.** Von außen ist der Port nicht erreichbar, und die Windows-Firewall fragt beim Start nichts nach. Diese Schranke fällt mit `AllowRemoteAccess` (siehe unten). 2. **Token** in jedem Handshake, zeitkonstant verglichen. 3. **Origin-Allowlist.** Für WebSockets gilt die Same-Origin-Policy *nicht* — der Browser blockiert nichts, es gibt keinen Preflight. Diese Prüfung ist damit nicht Härtung, sondern die eigentliche Schranke gegen fremde Seiten. Fehlt der Origin-Header ganz, ist der Aufrufer kein Browser; das ist zulässig, weil das Token bereits nachgewiesen wurde. Eine Webseite kann den Header nicht weglassen. 4. **Host-Prüfung** auf `127.0.0.1`, `localhost` bzw. `[::1]`. Ohne sie könnte eine Seite einen eigenen Namen auf 127.0.0.1 zeigen lassen (DNS-Rebinding). Entfällt ebenfalls mit `AllowRemoteAccess`. ### Zugriff von einem anderen Rechner Mit `"AllowRemoteAccess": true` bindet die App statt an die Loopback-Adressen an **alle Schnittstellen** (`0.0.0.0` und `::`) und ist damit im lokalen Netz erreichbar: ```js new WebSocket("ws://192.168.180.135:17654/?token=…"); ``` Die erreichbaren Adressen stehen beim Start im Protokoll, und *Verbindungsdaten kopieren* liefert dann ebenfalls die LAN-Adresse statt 127.0.0.1. Drei Dinge ändern sich damit: - **Die Host-Prüfung entfällt.** Unter welchem Namen oder welcher Adresse ein entfernter Rechner die App anspricht, kann sie nicht wissen. Der Schutz gegen DNS-Rebinding ruht damit allein auf Token und Origin-Liste. - **Die Windows-Firewall redet mit.** Ist sie aktiv, braucht es eine eingehende Regel, sonst kommt vom anderen Rechner nichts an: ```powershell New-NetFirewallRule -DisplayName "SwyxTray WebSocket" -Direction Inbound ` -Protocol TCP -LocalPort 17654 -Action Allow ``` Das erfordert Administratorrechte — die einzige Stelle des Vorhabens, an der sie nötig sind, und ein Grund, warum Loopback die Voreinstellung bleibt. - **`ws://` gilt aus der Ferne nicht mehr als vertrauenswürdig.** Die Ausnahme für Mixed Content gilt nur für Loopback. Eine **HTTPS**-Seite auf dem anderen Rechner kann `ws://192.168.…` also nicht öffnen — für den Test muss die Seite über `http://` kommen, oder es braucht `wss://` mit einem Zertifikat, das die App derzeit nicht anbietet. Wer den Zugang nicht nur zum Testen aus dem Netz erreichbar macht, sollte sich bewusst sein: Token und Origin-Liste sind dann das Einzige, was zwischen dem Netz und dem Telefon des Benutzers steht. ### Zur Zeit sind die Prüfungen abgeschaltet **Für die Erprobung entfallen Token-, Origin- und Host-Prüfung — in jedem Build.** Eine Seite verbindet sich ohne jede Einrichtung, auch direkt aus dem Dateisystem, wo der Browser den Origin `null` sendet: ```js new WebSocket("ws://127.0.0.1:17654/"); ``` Die Konfigurationsdatei wird nur noch für Port, Verbindungsgrenze und `AllowRemoteAccess` gelesen; ein fehlendes Token verhindert den Start nicht. Zusammen mit dem eingeschalteten Zugriff aus dem Netz heißt das: **jeder im selben Netz kann über diesen Anschluss telefonieren und mitlesen, wer anruft.** In diesem Zustand nicht ausliefern. Erkennbar ist das am Protokoll und am Kontextmenü, das dann *WebSocket: Port 17654, 1 Verbindung(en) — im Netz — OHNE PRUEFUNG* zeigt: ``` [INF] WebSocket-Zugang aus dem Netz erreichbar unter ws://192.168.180.135:17654/ — OHNE PRUEFUNG: Token, Origin und Host werden nicht geprueft. Jeder im selben Netz kann telefonieren und mitlesen. Nicht in diesem Zustand ausliefern. ``` Wieder einschalten lässt sich alles mit **einer Zeile** in [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) — die Prüfungen selbst stehen unverändert in [Web/WebSocketHandshake.cs](src/SwyxTray/Web/WebSocketHandshake.cs): ```csharp public static readonly bool SecurityDisabled = true; // <- zur Zeit ``` Der dort im Kommentar hinterlegte Ersatz macht daraus wieder ein `#if DEBUG` — dann prüft der Release-Build alles, während sich der Debug-Build weiterhin ohne Einrichtung erproben lässt. ### Was im Browser noch dazwischenkommen kann `ws://127.0.0.1` gilt als vertrauenswürdiger Ursprung und wird auch aus einer HTTPS-Seite heraus nicht als Mixed Content blockiert. Neuere Chrome-Versionen beschränken allerdings Zugriffe aus dem öffentlichen ins lokale Netz (*Local Network Access*) und fragen den Benutzer — das lässt sich per Unternehmensrichtlinie freigeben, aber nicht durch einen Antwort-Header lösen. **Vor dem Rollout mit der IT klären.** Browser lösen `localhost` häufig zuerst nach `::1` auf. Die App hört deshalb auf beiden Loopback-Adressen; sonst funktionierte `ws://127.0.0.1:…`, während `ws://localhost:…` ohne erkennbaren Grund scheiterte. ### Zwei Entwurfsentscheidungen **Kein `HttpListener`.** Der läuft über http.sys und braucht auch für 127.0.0.1 eine einmalige URL-Reservierung mit Administratorrechten (`netsh http add urlacl`). Die App soll ohne solche Rechte auskommen, deshalb ein schlichter `TcpListener`. Vom WebSocket-Protokoll bleibt dabei nur der HTTP-Upgrade von Hand zu schreiben — Rahmen, Maskierung, Ping/Pong und der Schließvorgang kommen von `WebSocket.CreateFromStream` aus der Klassenbibliothek. **Kommandos laufen über den UI-Thread.** CLMgr ist ein STA-COM-Server, und die App bündelt alle COM-Zugriffe im WinForms-Thread. Der [UiDispatcher](src/SwyxTray/Web/UiDispatcher.cs) schleust jedes Kommando aus dem Netzwerk-Thread dorthin, mit einer Zeitgrenze von 10 Sekunden: bliebe die Nachrichtenschleife hängen, fröre die Verbindung sonst stumm ein. ## Bauen und Starten ```powershell cd src\SwyxTray dotnet build -c Release .\bin\x64\Release\net10.0-windows\win-x64\SwyxTray.exe --verbose ``` `--verbose` schreibt zusätzlich jede CLMgr-Meldung ins Protokoll — für die erste Inbetriebnahme empfehlenswert. ### Aus VS Code (F5) Die Startkonfiguration **„SwyxTray debuggen (Desktop-Sitzung)"** in [.vscode/launch.json](.vscode/launch.json) baut, startet und hängt den Debugger an. Sie startet die App über eine geplante Aufgabe ([.vscode/start-desktop.ps1](.vscode/start-desktop.ps1)) — nötig, weil bei einer Remote-Verbindung (SSH, Tunnel) der Debugger Prozesse sonst in der Sitzung des VS-Code-Servers startet, meist Sitzung 0. Dort gibt es keine Taskleiste, die App läuft dann ohne sichtbares Tray-Symbol. Die geplante Aufgabe läuft dagegen mit dem interaktiven Token des Benutzers und landet auf dem Desktop; danach hängt sich der Debugger per Prozessname an. Eine bereits laufende Instanz wird vorher beendet, sonst griffe die Einzelinstanz-Sperre. Autostart: eine Verknüpfung auf die EXE in `shell:startup` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`) ablegen. Kein Dienst, keine Administratorrechte. ### Voraussetzungen - SwyxIt! muss laufen (getestet gegen 14.21.4345.0). - **.NET-10-Desktop-Laufzeit.** Auf diesem Rechner ist nur .NET 10 installiert, deshalb steht `net10.0-windows` in der csproj. Für .NET 8 einfach `net8.0-windows` setzen. Alternativ ohne Laufzeitabhängigkeit ausliefern: ```powershell dotnet publish -c Release -r win-x64 --self-contained true ``` - Die App läuft **in derselben Windows-Sitzung wie SwyxIt!** — als normale Benutzeranwendung, nicht als Dienst (siehe unten). ## Warum kein Windows-Dienst Ursprünglich war ein Windows-Dienst vorgesehen. Das funktioniert für diesen Zweck nicht: ein Dienst läuft in **Session 0**, der SwyxIt!-Client und sein `CLMgr.exe` in der Benutzersitzung (1, 2, …). Über die Sitzungsgrenze hinweg lässt sich der laufende Client nicht ansprechen. Ein Dienst müsste dafür einen Hilfsprozess per `CreateProcessAsUser` in die Benutzersitzung starten und mit ihm über eine Named Pipe reden. Die Tray-Anwendung umgeht das Problem vollständig, weil sie von vornherein in der richtigen Sitzung läuft. ## Stand der Prüfung - **Übersetzung gegen das echte SDK: erfolgreich.** Alle verwendeten Typen und Signaturen stammen aus `Interop64.CLMgr.dll` (Version 2.0.0.0) des Pakets und wurden gegen die installierte TypeLib `CLMgr 2.0` (`{F8E552F7-4C00-11D3-80BC-00105A653379}`, registriert auf `C:\Program Files (x86)\Swyx\SwyxIt!\CLMgr.exe`) verifiziert. **Gegen den echten, laufenden Client verifiziert** (SwyxIt! 14.21.4345.0): ``` [INF] SwyxTray gestartet (PID 3348, Sitzung 0, x64). [INF] Mit CLMgr verbunden (UserId 0, Benutzer 'Assecutor, G.Timm', Server '127.0.0.1', 4 Leitungen). [DBG] Zustand: Idle | L1=LSInactive(0) * | L2=LSInactive(0) | L3=LSInactive(0) | L4=LSInactive(0) ``` Bestätigt sind damit: COM-Aktivierung, `DispInit`, `DispRegisterUser`, Anmelden der Ereignissenke, Auslesen aller vier Leitungen samt Zustand und ausgewählter Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung (keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll. **Adressdaten gegen den laufenden Client verifiziert:** Das globale Telefonbuch wird mit allen **404 Einträgen** gelesen (`Adressdaten gelesen: 404 Einträge`, knapp eine Sekunde nach dem Verbindungsaufbau). Dabei zeigte sich, dass CLMgr die Telefonbücher als reine IDispatch-Objekte liefert: weder die Sammlung noch ihre Einträge implementieren die typisierten Interop-Schnittstellen (`IDispCollection`, `IPbxPhoneBookEntryDisp`) — ein Cast darauf scheitert still und ergibt 0 Einträge. Gelesen wird deshalb per `IEnumerable` und Late-Binding (`dynamic`), siehe `AppendPhoneBook` in [Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs). **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)