Files
swyx/README.md
T
SvenandClaude Fable 5 fcd0d62d78 Adressdaten aus dem Swyx-Client; core-Modul und Firefox-Plugin ausgelagert
SwyxTray liest jetzt die Telefonbuecher (global und persoenlich) des
SwyxIt!-Clients in einen Adress-Cache, bietet Kontaktsuche ueber
FulltextSearchInContactsEx und Nummernaufloesung ueber DispResolveNumber;
alles ueber den WebSocket-Zugang abrufbar.

Das Spring-Boot-Modul core/ und das SwyxFFPlugin werden als eigene
Projekte gepflegt und aus diesem Repository entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:10:13 +02:00

31 KiB

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:

<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:

  • IClientLineMgrDispDispInit, DispRegisterUser, DispNumberOfLines, DispGetLine, DispSelectedLineNumber, DispIsServerUp, DispGetCurrentUser, DispGetCurrentServer, DispReleaseUser, FulltextSearchInContactsEx, DispResolveNumber, DispClientConfig
  • IClientLineDispDispState, DispPeerNumber, DispPeerName
  • IClientConfigPbxPhoneBookEnumerator, 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_EventPubOnLineMgrNotification(msg, param)
  • CLMgrLineStates — die Leitungszustände als typisierte Aufzählung

Aufbau

Datei Zweck
Program.cs Einstieg, STA-Thread, Einzelinstanz-Sperre, Fehlerprotokollierung
TrayApplicationContext.cs NotifyIcon, Kontextmenü, Sprechblasen
TrayIcons.cs erzeugt die Symbole zur Laufzeit (keine .ico-Dateien nötig)
Swyx/SwyxClient.cs CLMgr-Verbindung, Ereignisbehandlung, Zustandsabfrage
Swyx/SwyxModels.cs SwyxSnapshot, SwyxLineInfo, Gesamtzustand
Swyx/LineStateExtensions.cs Einordnung und deutscher Klartext der CLMgrLineStates
Swyx/PubCLMgrMessages.cs Meldungsnummern aus CLMgrPubTypes.h
Web/LocalWebSocketServer.cs Listener auf 127.0.0.1 und ::1, Verbindungsverwaltung, Verteilen der Zustände
Web/WebSocketHandshake.cs HTTP-Upgrade, Prüfung von Token, Origin und Host
Web/WebSocketSession.cs eine Verbindung: Empfangsschleife, serialisiertes Senden
Web/CommandExecutor.cs Kommandos → SwyxClient, mit Zeitgrenze
Web/UiDispatcher.cs Sprung aus den Netzwerk-Threads in den UI-Thread
Web/Protocol.cs die JSON-Nachrichten
Web/WebSocketConfig.cs Port, Token, erlaubte Origins
examples/browser-client.html Beispielseite zum Erproben des Zugangs
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 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:

{
  "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

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.

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:

 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):

 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:

 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-firefoxTemporä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://<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
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.

 {"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.

 {"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:

 {"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:

{"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.

{"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
{"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 incomingconnected (mit "direction":"incoming") → ended, jeweils mit einem snapshot davor. Wählt die Seite selbst per Kommando call, folgen outgoingconnectedended 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:

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:

    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:

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 — die Prüfungen selbst stehen unverändert in Web/WebSocketHandshake.cs:

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 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

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 baut, startet und hängt den Debugger an. Sie startet die App über eine geplante Aufgabe (.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:

    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.

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